diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 9be953c6c6..7cc59cffb0 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,10 +1,12 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from '@deepseek-ai/cordis' +import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' +export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails. @@ -17,12 +19,22 @@ export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-llm/types' export type {} from '@deepseek-ai/dsh-agent-presets/types' export type {} from '@deepseek-ai/dsh-settings/types' + /** - * The Gateway Client face's own declaration merges, type-only: `ctx.remote` and - * with it the `$on`/`$dispatch` surface. Erased at emit, so this facade still - * carries no runtime edge to the Gateway implementation. + * The carrier's Client-facing types, re-exported so a business package names one + * assembly package instead of both this facade and the Connection plugin. Type-only: + * the carrier's runtime values stay behind their own module edge. */ -export type {} from '@deepseek-ai/dsh-api-gateway/client' +export type { + ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, + CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient, + MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, + MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt, + RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem, + SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, + SubagentAddress, SubagentCatalog, TaskView, ToolCallView, ToolEventView, ToolResultView, + WorkspaceId, WorkspaceView +} from '@deepseek-ai/dsh-client-connection/client' declare module '@deepseek-ai/cordis' { interface Context { @@ -40,5 +52,16 @@ export const inject = ['remote'] * @returns disposer after every selected Remote namespace is ready. */ export async function apply(ctx: Context): Promise<() => Promise> { - return await ctx.remote.$mount(goalsRemote) + const disposers: Array<() => Promise> = [] + try { + for (const contribution of [commandsRemote, goalsRemote]) { + disposers.push(await ctx.remote.$mount(contribution)) + } + } catch (error) { + for (const dispose of disposers.reverse()) await dispose() + throw error + } + return async () => { + for (const dispose of disposers.reverse()) await dispose() + } } diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 980a2f02a8..2eca23399a 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -10,7 +10,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, - CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, + SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, GoalsApi, GoalRef, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 447b46340a..fdd45120a4 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -28,6 +28,7 @@ import type { // 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 { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, @@ -1379,11 +1380,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return createFixtureWorld(options).api } -interface FixtureWorld { +/** Both fixture faces over one state graph. */ +export interface FixtureWorld { + /** Legacy unary/stream API the fixture still answers. */ readonly api: ApiProxy + /** Generic Remote caller for the endpoints business services own. */ readonly rpc: ClientConnectionRpc } +/** + * Build both fixture faces so a caller can drive the Remote endpoints and the + * legacy API against one in-memory state graph. + * @param options - fixture branches for empty state and failure timing. + * @returns the legacy API face and the Remote RPC face. + */ +export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { + return createFixtureWorld(options) +} + /** Build the fixture's legacy API and Remote RPC faces over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. @@ -1598,6 +1612,98 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { : undefined ) + /** Canonical fixture implementation of the generated Commands Remote contract. */ + const commandRemotes = { + list(id: SessionId): RpcResult { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + return { + ok: true, + value: [ + { name: 'compact', description: 'fixture:压缩当前会话上下文' }, + { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, + { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, + ], + } + }, + execute(id: SessionId, line: string): RpcResult { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + // Structured split mirroring the Host parser: name + verbatim rawInput + // (separator whitespace included) — the run payload carries no line. + const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim()) + const name = match?.[1] + const args = match?.[2] ?? '' + if (name === 'permission') { + const preset = args.trim() + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const spec = PERMISSION_PRESETS[preset] + let result: CommandResult + if (preset === '') { + const current = permissionSelectOf(logOf(id)).currentValue + result = { kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } + } else if (spec === undefined) { + result = { kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } + } else { + if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) + append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) + append(id, { type: 'approval/policy', data: { policy: spec.approval } }) + result = { kind: 'success', text: `preset ${preset}` } + } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + } + if (name === 'goal') { + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const objective = args.trim() + const current = backscanGoal(logOf(id)) + let text: string + if (objective === '') { + text = current === null ? 'No goal is set. Usage: /goal ' : `Current goal: ${current.goal.objective}` + } else if (current !== null && current.goal.phase !== 'complete') { + text = `A goal already exists (${current.goal.objective}). Clear it first.` + } else { + const created = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 }, + roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), + }) + text = `Goal created: ${created.goal.objective}` + } + const result: CommandResult = { kind: 'success', text } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + } + const running = summaryOf(id)?.running === true + const outcomes: Record = { + compact: 'fixture:已压缩(假动作)', + echo: args.trim(), + plan: args.trim() === 'off' + ? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.') + : (running + ? 'Entering plan mode (applies from the next step). Use /plan off to leave.' + : 'Plan mode on. Use /plan off to leave.'), + } + const text = name === undefined ? undefined : outcomes[name] + if (name === undefined || text === undefined) return { ok: true, value: undefined } + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + if (name === 'plan' && !running) { + const plan = foldPlan(logOf(id)) + if (plan.wanted !== null && plan.wanted !== plan.active) { + append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + } + } + const result: CommandResult = { kind: 'success', ...text === '' ? {} : { text } } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + }, + } + const goalView = (projection: FxGoalProjection): FxGoalView => ({ ...projection.goal, roundsStarted: projection.roundsStarted, @@ -2446,104 +2552,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return ok(request, { archivedSessionIds: [...archivedSessionIds] }) }, }, - commands: { - // The catalog mirrors one session's effective view (every fixture - // session has an agent, like the real host). - list: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - return ok(request, { - commands: [ - { name: 'compact', description: 'fixture:压缩当前会话上下文' }, - { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, - { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, - ], - }) - }, - // Pure admission, mirroring the host: an admitted command logs the - // command/run + command/done lifecycle pair (mux-broadcast by append), - // and the response only reports resolution. - execute: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const id = request.payload.sessionId - // Structured split mirroring the host parser: name + verbatim rawInput - // (separator whitespace included) — the run payload carries no line. - const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim()) - const name = match?.[1] - const args = match?.[2] ?? '' - // /permission mirrors the host handler: switch through the knob - // events (each append pushes a permissions projection frame). - if (name === 'permission') { - const preset = args.trim() - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - const spec = PERMISSION_PRESETS[preset] - if (preset === '') { - const current = permissionSelectOf(logOf(id)).currentValue - append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) - } else if (spec === undefined) { - append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) - } else { - if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) - append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) - append(id, { type: 'approval/policy', data: { policy: spec.approval } }) - append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } }) - } - return ok(request, { matched: true as const, commandId }) - } - if (name === 'goal') { - // Host parallel: /goal with an objective creates (or reports) the - // current goal; the command lifecycle pair brackets the mutation. - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - const objective = args.trim() - const current = backscanGoal(logOf(id)) - let text: string - if (objective === '') { - text = current === null ? 'No goal is set. Usage: /goal ' : `Current goal: ${current.goal.objective}` - } else if (current !== null && current.goal.phase !== 'complete') { - text = `A goal already exists (${current.goal.objective}). Clear it first.` - } else { - const created = appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'create', - goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 }, - roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), - }) - text = `Goal created: ${created.goal.objective}` - } - append(id, { type: 'command/done', data: { commandId, kind: 'success', text } }) - return ok(request, { matched: true as const, commandId }) - } - // Host parallel: /plan on an idle fixture session commits plan/mode - // immediately (the boundary flush covers only a running turn), so the - // outcome copy matches the immediate branch of the host handler. - const running = summaryOf(id)?.running === true - const outcomes: Record = { - compact: 'fixture:已压缩(假动作)', - echo: args.trim(), - plan: args.trim() === 'off' - ? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.') - : (running - ? 'Entering plan mode (applies from the next step). Use /plan off to leave.' - : 'Plan mode on. Use /plan off to leave.'), - } - 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}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - if (name === 'plan' && !running) { - const plan = foldPlan(logOf(id)) - if (plan.wanted !== null && plan.wanted !== plan.active) { - append(id, { type: 'plan/mode', data: { active: plan.wanted } }) - } - } - append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } }) - return ok(request, { matched: true as const, commandId }) - }, - }, agentPresets: { // Both trusts appear, because a surface must present a locally authored // preset differently from one the deployment vetted. @@ -2852,12 +2860,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const args = (payload as { args: { agentId: SessionId + line?: string ref?: { id: string; revision: number } request?: { objective?: string; maxGoalRounds?: number } } }).args const sessionId = args.agentId switch (endpoint) { + case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) + case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string)) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: args.request?.objective as string, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, @@ -2950,8 +2961,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) - case 'command.list': return this.api.commands.list(request) - case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index dadcaf5538..1b909e380a 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -18,7 +18,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, - CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, + SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index ff35b4d892..d2cec5ed54 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -1,9 +1,8 @@ // 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, ModelSelection, MuxFrame, + HostFrame, IApiClient, ModelSelection, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -169,19 +168,10 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. - onCommandList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - readonly commands: IApiClient['commands'] = { - list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), - execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), - } - readonly agentPresets: IApiClient['agentPresets'] = { list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), select: (payload: { agentPreset: string }) => diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index 70ee127b3d..909d0baf46 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -1,28 +1,35 @@ /** - * Fixture commands/skills domains: contract-shape conformance for the two - * domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute - * parse/dispatch, skill.list session resolution, and the FixtureApiClient - * dispatch rows. + * Fixture commands/skills domains: session-addressed catalogs, execute + * parse/dispatch and its logged lifecycle pair, skill.list session resolution, + * and the FixtureApiClient dispatch rows. Commands answer on the Remote face + * and skills on the legacy API face, so both are driven here. */ import { describe, expect, it } from 'vitest' import type { SessionId } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' import type { RpcRequest } from '../src/client/api.ts' -import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' +import { FixtureApiClient, createFixtureApi, createFixtureFaces } from '../src/client/fixture.ts' + +/** Drive one commands Remote endpoint against the fixture state graph. */ +async function callRemote( + rpc: ReturnType['rpc'], + endpoint: string, + args: Record, +): Promise { + const result = await rpc.call('/api', endpoint, { args }) + if (!result.ok) throw new Error(`${endpoint} failed: ${result.error.code}`) + return result.value as T +} const sid = (id: string): SessionId => id as SessionId let reqCount = 0 const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${reqCount++}`), payload }) -const signal = new AbortController().signal describe('createFixtureApi commands/skills', () => { - it('serves the addressed session catalog with rpcId echo', async () => { - const api = createFixtureApi() - const request = req({ sessionId: sid('fx-alpha') }) - const response = await api.commands.list(request) - expect(response.rpcId).toBe(request.rpcId) - if (!response.result.ok) throw new Error('list failed') - const commands = response.result.value.commands + it('serves the addressed session catalog', async () => { + const { rpc } = createFixtureFaces() + const commands = await callRemote<{ name: string; input?: { hint: string } }[]>( + rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') @@ -31,13 +38,13 @@ describe('createFixtureApi commands/skills', () => { }) it('rejects a catalog request for an unknown session', async () => { - const api = createFixtureApi() - const response = await api.commands.list(req({ sessionId: sid('fx-nope') })) - expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + const { rpc } = createFixtureFaces() + const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } }) + expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => { - const api = createFixtureApi() + const { api, rpc } = createFixtureFaces() const frames: unknown[] = [] const abort = new AbortController() const stream = api.events.mux(req({}), abort.signal) @@ -47,10 +54,9 @@ describe('createFixtureApi commands/skills', () => { if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() } })() - const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal) - if (!response.result.ok) throw new Error('execute failed') - expect(response.result.value).toMatchObject({ matched: true }) - expect(response.result.value.commandId).toBeTruthy() + const execution = await callRemote<{ commandId: string } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hello world' }) + expect(execution?.commandId).toBeTruthy() await pump const events = frames .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') @@ -63,22 +69,23 @@ describe('createFixtureApi commands/skills', () => { }) it('addresses execute to the session; an unknown session errs', async () => { - const api = createFixtureApi() - const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal) - if (!hit.result.ok) throw new Error('execute failed') - expect(hit.result.value.matched).toBe(true) + const { rpc } = createFixtureFaces() + const hit = await callRemote<{ commandId: string } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship' }) + expect(hit?.commandId).toBeTruthy() - const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + const missing = await rpc.call('/api', 'commands/execute', { + args: { agentId: sid('fx-nope'), line: '/goal ship' }, + }) + expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) - it('falls to matched:false on unknown names and non-command lines', async () => { - const api = createFixtureApi() + it('answers no execution for unknown names and non-command lines', async () => { + const { rpc } = createFixtureFaces() for (const line of ['/nope', 'plain text', '/']) { - const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal) - if (!response.result.ok) throw new Error('execute failed') - // Pure admission value: the matched bit is the whole response shape. - expect(response.result.value).toEqual({ matched: false }) + // Absence is the whole answer: nothing matched, so no lifecycle id exists. + expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line })) + .toBeUndefined() } }) @@ -94,14 +101,13 @@ describe('createFixtureApi commands/skills', () => { }) describe('FixtureApiClient command/skill dispatch', () => { - it('routes the three method keys through the in-memory dispatch table', async () => { + it('routes the Remote commands face and the legacy skill row through one state graph', async () => { const client = new FixtureApiClient() - const list = await client.commands.list({ sessionId: sid('fx-alpha') }) - if (!list.result.ok) throw new Error('command.list failed') - expect(list.result.value.commands.length).toBeGreaterThan(0) - const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' }) - if (!executed.result.ok) throw new Error('command.execute failed') - expect(executed.result.value.matched).toBe(true) + const commands = await callRemote<{ name: string }[]>(client.rpc, 'commands/list', { agentId: sid('fx-alpha') }) + expect(commands.length).toBeGreaterThan(0) + const executed = await callRemote<{ commandId: string } | undefined>( + client.rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' }) + expect(executed?.commandId).toBeTruthy() const skills = await client.skills.list({ sessionId: sid('fx-alpha') }) if (!skills.result.ok) throw new Error('skill.list failed') expect(skills.result.value.skills.length).toBeGreaterThan(0) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index be2bfa4172..d4d65c8c8b 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -34,7 +34,7 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-typert-registry", - "@deepseek-ai/dsh-api-gateway" + "@deepseek-ai/dsh-api-remotes" ], "platform": "web", "immediately": true @@ -59,19 +59,19 @@ "zustand": "~4.4.7" }, "peerDependencies": { - "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1" }, "files": [ diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index b32840daa0..d2bf7d074c 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -17,7 +17,7 @@ */ import { Context as CordisContext } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta' /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index 26e7e43c67..f14fb02d7c 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client' /* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents -- * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program; diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 95bff54915..574b7c9f6a 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -10,7 +10,8 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId, PromptContentPart, QueueAction, RpcResult, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' import type { ConversationSnapshot } from '../sessions/conversation.ts' import type { ObservableSnapshot } from './store.ts' @@ -75,9 +76,9 @@ export interface ISession { * Execute one slash-command line against this session's agent — pure * admission semantics (the host executor durably logs the lifecycle). * @param line - the full command line, leading slash included. - * @returns the admission result, or the error branch on transport failure. + * @returns the admission result, or the Remote face's error branch. */ - command(line: string): Promise> + command(line: string): Promise> } /** diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts index 466e26fe12..0eec0f5f86 100644 --- a/packages/client/runtime/src/client/contract/sessions-port.ts +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -7,7 +7,7 @@ * dependency. */ -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-api-remotes/client' import type { ObservableSnapshot } from './store.ts' /** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */ diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 960d2038de..27b9d2d13b 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -10,7 +10,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { RpcResult, SessionId, SubagentAddress, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentContext } from '../agents/scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index ad896bbdaf..8441de8eb0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -6,7 +6,7 @@ * the concrete class. Widening this interface is the explicit act of * widening what features may do to the workspaces domain. */ -import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { WorkspaceListState } from '../workspaces/service.ts' import type { ObservableSnapshot } from './store.ts' diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 4f80cd14e6..1b176254b9 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,10 +1,10 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather // than api-remotes': that face imports a Host-tsdown-generated artifact, and this // project sits in the Host build graph. -import type {} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' @@ -179,7 +179,7 @@ declare module '@deepseek-ai/cordis' { } /** Required services: the wire handle and Client TypeRT registry. */ -export const inject = ['connection', 'typert', 'remote'] +export const inject = ['connection', 'typert', 'remote', 'remote.commands'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. @@ -191,7 +191,7 @@ export function apply(ctx: Context): void { views: new ConversationViewRegistry(ctx), } const connection = ctx.get('connection') as ConnectionHandle - const sessions = new SessionsService(ctx, connection.api, conversation) + const sessions = new SessionsService(ctx, connection.api, ctx.remote, conversation) ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), }) diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 7972ce5e50..f39156baf2 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -13,7 +13,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' import type { diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index cf8fa0834d..7579310f49 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -2,7 +2,7 @@ // The input order is authoritative; lineage only makes each child adjacent to its parent. // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { PendingInteractionStatus } from './pending.ts' diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index acb56fbde0..3cc46843fc 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -5,7 +5,7 @@ import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-session-title/client' import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' +import type { SessionRemotes } from './remotes.ts' /** * List arrival lifecycle, orthogonal to the pull-activity `state` axis: @@ -164,6 +165,7 @@ export class SessionManager { */ constructor( private readonly api: IApiClient, + private readonly remote: SessionRemotes, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, private readonly conversation?: ConversationRuntime, @@ -304,7 +306,7 @@ export class SessionManager { private createSession(sessionId: SessionId): Session { const address = this.addresses.get(sessionId) - return new Session(sessionId, this.api, { + return new Session(sessionId, this.api, this.remote, { ...(address === undefined ? {} : { address, parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false, diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts index 1383faea35..b8b3492a04 100644 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ b/packages/client/runtime/src/client/sessions/pending.ts @@ -4,7 +4,7 @@ import type { ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' /** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */ export interface PendingPayloads { diff --git a/packages/client/runtime/src/client/sessions/queue-mirror.ts b/packages/client/runtime/src/client/sessions/queue-mirror.ts index be7351cf5f..1eb4e6fdbe 100644 --- a/packages/client/runtime/src/client/sessions/queue-mirror.ts +++ b/packages/client/runtime/src/client/sessions/queue-mirror.ts @@ -1,5 +1,5 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client' +import type { MuxFrame } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { QueuedMessage } from './conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/remotes.ts b/packages/client/runtime/src/client/sessions/remotes.ts new file mode 100644 index 0000000000..4fa503345b --- /dev/null +++ b/packages/client/runtime/src/client/sessions/remotes.ts @@ -0,0 +1,12 @@ +/** + * Remote namespaces the Session cluster calls. One parameter for one concept: + * the generated surface a Session and its manager reach the Host through. + * + * @module @deepseek-ai/dsh-client-runtime/client/sessions/remotes + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' + +/** The generated Remote namespaces a Session and its manager call. */ +export type SessionRemotes = Pick diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b7267402e..23438554ea 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -17,7 +17,7 @@ import type { Context, Fiber } from '@deepseek-ai/cordis' import type { IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -32,6 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import type { ConversationRuntime } from './conversation-assembler.ts' import { SessionManager } from './manager.ts' +import type { SessionRemotes } from './remotes.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { PendingInteractionStatus } from './pending.ts' import { SessionProvideChannel } from './provide.ts' @@ -271,11 +272,13 @@ export class SessionsService implements ISessions { /** * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. + * @param remote - generated Remote namespaces shared with every Session. * @param conversationRuntime - same-pass registry instances, when runtime apply owns them. */ constructor( private readonly rootCtx: Context, api: IApiClient, + remote: SessionRemotes, conversationRuntime?: ConversationRuntime, ) { this.selection = createSnapshotStore( @@ -291,6 +294,7 @@ export class SessionsService implements ISessions { ) this.manager = new SessionManager( api, + remote, restored.sessionId, restored.subagentAddress, conversation, diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 5ab20a07cd..fc50cbb499 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError, RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -21,6 +21,8 @@ import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' import { Notifier } from './notifier.ts' +import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' +import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' import { resolvedClientTimeZone } from '../time-zone.ts' @@ -134,11 +136,13 @@ export class Session implements SessionFace { /** * @param sessionId - Host session identity (client sessions are always Host-born). * @param api - shared wire client. + * @param remote - generated Remote namespaces this session calls. * @param options - optional manager-owned state observers. */ constructor( readonly sessionId: SessionId, private readonly api: IApiClient, + private readonly remote: SessionRemotes, private readonly options: SessionOptions = {}, ) { this.projections = options.projections ?? new ProjectionValueStore() @@ -351,12 +355,10 @@ export class Session implements SessionFace { * @param line - the full command line, leading slash included. * @returns the admission result, or the error branch on transport failure. */ - async command(line: string): Promise> { - try { - return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result - } catch (error) { - return transportError(error) - } + async command(line: string): Promise> { + const result = await this.remote.commands.execute(this.sessionId, line) + if (!result.ok) return result + return { ok: true, value: { matched: result.value !== undefined } } } /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ diff --git a/packages/client/runtime/src/client/sessions/subagent-lineage.ts b/packages/client/runtime/src/client/sessions/subagent-lineage.ts index fb56b4eb8c..518f45ab1e 100644 --- a/packages/client/runtime/src/client/sessions/subagent-lineage.ts +++ b/packages/client/runtime/src/client/sessions/subagent-lineage.ts @@ -4,7 +4,7 @@ * uninterrupted subagent subtree. * @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage */ -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionSummary } from './service.ts' /** Descendant counts projected for one possible parent session. */ diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index df3aa8fe28..dc618977d8 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -2,7 +2,7 @@ import type { HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { Notifier } from '../sessions/notifier.ts' diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 468ae95a19..733a894c7b 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -4,7 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { DirectoryListing, IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts' diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts index 6d24534c6c..a9eec992a3 100644 --- a/packages/client/runtime/src/client/workspaces/workspace.ts +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -2,7 +2,7 @@ import type { IApiClient, RpcResult, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import { Notifier } from '../sessions/notifier.ts' diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 50812ee13c..40b7461b37 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -5,8 +5,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +import type { ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts index 5181dab926..79d63f3226 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' import type { @@ -8,7 +8,7 @@ import type { } from '../src/client/contract/conversation.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionsService } from '../src/client/sessions/service.ts' -import { FakeApiClient, ok } from './fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.ts' function eventDefinition(kind: string): ConversationNodeDefinition { return { @@ -145,7 +145,7 @@ describe('Conversation registries', () => { api.onList = () => Promise.resolve(ok({ items: [{ sessionId, updatedAt: 1, running: false, blank: true }], }) as never) - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) await sessions.refresh() await Promise.resolve() sessions.scope(sessionId) diff --git a/packages/client/runtime/tests/conversation.spec.ts b/packages/client/runtime/tests/conversation.spec.ts index 345f21b598..1237f73ae8 100644 --- a/packages/client/runtime/tests/conversation.spec.ts +++ b/packages/client/runtime/tests/conversation.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client' +import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client' import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts' describe('toAssistantBlock', () => { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 78d9605edd..94766c09e2 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -1,13 +1,13 @@ // 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, ModelSelection, MuxFrame, + ClientResponse, HostFrame, IApiClient, ModelSelection, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' /** Programmable-default workspace row (branded id, ISO-ish times). */ function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { @@ -55,6 +55,20 @@ interface StreamConn { feed(item: StreamItem): void } +/** + * Commands Remote double: the generated face delivers the carrier's outcome, so + * a test that programs nothing sees an empty catalog and an unmatched line. + * @returns the Remote namespaces the session cluster calls. + */ +export function fakeRemote(): SessionRemotes { + return { + commands: { + list: () => Promise.resolve({ ok: true, value: [] }), + execute: () => Promise.resolve({ ok: true, value: undefined }), + }, + } +} + export class FakeApiClient implements IApiClient { /** Chronological call record: [method, payload]. */ readonly calls: { method: string; payload: unknown }[] = [] @@ -205,19 +219,10 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program requires-bearing catalogs and dual-address // skill lists without casts. - onCommandList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - readonly commands: IApiClient['commands'] = { - list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), - execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), - } - readonly agentPresets: IApiClient['agentPresets'] = { list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), select: (payload: { agentPreset: string }) => diff --git a/packages/client/runtime/tests/lineage.spec.ts b/packages/client/runtime/tests/lineage.spec.ts index 7d3c948f3e..01ecacd1e2 100644 --- a/packages/client/runtime/tests/lineage.spec.ts +++ b/packages/client/runtime/tests/lineage.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client' import { flattenLineage } from '../src/client/sessions/lineage.ts' const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 92d4af622e..dec157341a 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -4,9 +4,9 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' import { entries, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId @@ -28,7 +28,7 @@ describe('instances', () => { it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() const session = manager.get(S1) expect(manager.get(S1)).toBe(session) // resident: same instance forever @@ -37,7 +37,7 @@ describe('instances', () => { it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) // Uninstantiated: approval buffers, plain session/event drops. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) @@ -50,7 +50,7 @@ describe('instances', () => { it('retains every live answerable request and compacts resolutions before instantiation', () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) for (let i = 0; i < 40; i++) { manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) @@ -67,7 +67,7 @@ describe('instances', () => { }) it('drops buffered answerable requests on session removal', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) // Removed session: buffered frames must not replay on a future instantiation. manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) @@ -80,7 +80,7 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') @@ -96,7 +96,7 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const first = deferred>>() api.onList = () => first.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const hydration = manager.refreshList() manager.handleHostEnvelope({ rpcId: 'during-first' as never, @@ -116,7 +116,7 @@ describe('list lifecycle', () => { it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) // A failed pull does not step the arrival phase: still pending. @@ -125,7 +125,7 @@ describe('list lifecycle', () => { it('phase steps pending → ready on the first successful pull and never returns', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) expect(manager.getListSnapshot().phase).toBe('pending') await manager.refreshList() expect(manager.getListSnapshot().phase).toBe('ready') @@ -144,7 +144,7 @@ describe('list lifecycle', () => { it('merges create into the list immediately without waiting for a refresh', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const result = await manager.create() expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) @@ -152,7 +152,7 @@ describe('list lifecycle', () => { it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const titleFrame = (rpcId: string, title: string, seq: number) => { manager.handleMuxEnvelope({ rpcId: rpcId as never, @@ -179,7 +179,7 @@ describe('list lifecycle', () => { it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) // A push frame landed before the list (S2's title is newer than the block's cut). manager.handleMuxEnvelope({ rpcId: 'push-newer' as never, @@ -202,7 +202,7 @@ describe('list lifecycle', () => { it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() const frame = (rpcId: string, payload: object) => { manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never }) @@ -230,7 +230,7 @@ describe('search', () => { items: [{ sessionId: S1, snippet: 'matching excerpt' }], hasMore: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const signal = new AbortController().signal await expect(manager.search('exact phrase', signal)).resolves.toEqual({ @@ -246,7 +246,7 @@ describe('search', () => { it('preserves business errors and folds transport failures', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) api.onSearch = () => Promise.resolve(err({ code: 'internal', message: 'index unavailable', @@ -269,7 +269,7 @@ describe('search', () => { describe('host frame routing', () => { it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored expect(manager.getListSnapshot().items).toHaveLength(1) @@ -303,7 +303,7 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() await manager.refreshSubagents(S1) manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) @@ -368,7 +368,7 @@ describe('subagent catalogs', () => { vi.useFakeTimers() try { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshSubagents(S1) manager.setSubagentCatalogOpen(S1, true) await Promise.resolve() @@ -418,7 +418,7 @@ describe('subagent catalogs', () => { ] as never[], parentAvailable: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshSubagents(root) manager.handleHostEnvelope({ @@ -447,7 +447,7 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshSubagents(root) manager.handleHostEnvelope({ @@ -488,7 +488,7 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshSubagents(root) manager.handleHostEnvelope({ @@ -529,7 +529,7 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshSubagents(S1) manager.handleHostEnvelope({ @@ -547,7 +547,7 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) @@ -566,7 +566,7 @@ describe('subagent catalogs', () => { const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, root) + const manager = new SessionManager(api, fakeRemote(), root) const refresh = manager.refreshSubagents(root) // A membership frame arrives while the pull is in flight; the debounced @@ -624,7 +624,7 @@ describe('subagent catalogs', () => { }) const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshSubagents(root) first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await refresh @@ -671,7 +671,7 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshSubagents(root) manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) @@ -690,14 +690,14 @@ describe('remaining branches', () => { it('refreshList folds a transport throw into the error state', async () => { const api = new FakeApiClient() api.onList = () => Promise.reject(new Error('list wire down')) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) }) it('refreshList pushes running bits down to already-instantiated sessions', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const session = manager.get(S1) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) await manager.refreshList() @@ -707,7 +707,7 @@ describe('remaining branches', () => { it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) @@ -727,7 +727,7 @@ describe('remaining branches', () => { message: 'published but unattached', details: { sessionId: S1, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) @@ -741,7 +741,7 @@ describe('remaining branches', () => { message: 'forked but unattached', details: { sessionId: S2, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const result = await manager.fork({ sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ @@ -754,7 +754,7 @@ describe('remaining branches', () => { it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) expect(manager.getListSnapshot().items).toEqual([]) @@ -775,7 +775,7 @@ describe('remaining branches', () => { it('subscribe notifies on list changes and stops after unsubscribe', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) let notified = 0 const unsubscribe = manager.subscribe(() => { notified++ }) await manager.refreshList() @@ -790,7 +790,7 @@ describe('remaining branches', () => { it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never }) @@ -805,7 +805,7 @@ describe('remaining branches', () => { it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() const before = manager.getListSnapshot() manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) @@ -821,7 +821,7 @@ describe('remaining branches', () => { it('carries parentSessionId from host/session-added into the lineage row', () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, @@ -845,7 +845,7 @@ describe('connected generation', () => { hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const openedSession = manager.get(S1) await openedSession.open() manager.get(S2) // instantiated but never opened @@ -863,7 +863,7 @@ describe('connected generation', () => { const address = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, } - const manager = new SessionManager(api, S2, address) + const manager = new SessionManager(api, fakeRemote(), S2, address) manager.handleConnected() @@ -876,7 +876,7 @@ describe('connected generation', () => { describe('pending-interaction list status', () => { it('tracks approval requests through replay and resolution without instantiation', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) @@ -889,7 +889,7 @@ describe('pending-interaction list status', () => { }) it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'q1' as never, @@ -922,7 +922,7 @@ describe('pending-interaction list status', () => { ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'q-plan' as never, @@ -939,7 +939,7 @@ describe('pending-interaction list status', () => { }) it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ @@ -958,7 +958,7 @@ describe('pending-interaction list status', () => { }) it('drops stale status at generation death before replay re-adds live interactions', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') @@ -973,7 +973,7 @@ describe('pending-interaction list status', () => { }) it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) // Buffered pre-instantiation: an approval pair and a queued row. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) @@ -1000,7 +1000,7 @@ describe('completed reminder', () => { manager.getListSnapshot().items.find(item => item.sessionId === sessionId) it('arms on a running→idle flip of a non-selected session and clears on select', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h2', S2)) manager.select(S1) @@ -1014,7 +1014,7 @@ describe('completed reminder', () => { }) it('never arms for the session being watched and re-arms after a switch-away re-run', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h2', S2)) manager.select(S2) @@ -1029,7 +1029,7 @@ describe('completed reminder', () => { }) it('a re-run disarms the reminder while running and re-arms on its completion', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h2', S2)) manager.select(S1) @@ -1044,7 +1044,7 @@ describe('completed reminder', () => { }) it('session-removed drops the reminder and a re-add starts clean', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h2', S2)) manager.select(S1) @@ -1060,7 +1060,7 @@ describe('completed reminder', () => { it('a list refresh carrying the running→idle transition arms the reminder', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -1072,7 +1072,7 @@ describe('completed reminder', () => { it('never arms for sessions already idle at first observation', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -1085,7 +1085,7 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshList() // The session finishes while the first pull is still in flight; the pull // response recorded it as running at pull time. @@ -1099,7 +1099,7 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshList() // The unknown session starts and finishes while the first pull is in // flight; the pull-time baseline recorded it idle, so the running→idle @@ -1120,7 +1120,7 @@ describe('background-task mirror', () => { ({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never }) it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) const first = manager.getListSnapshot().tasksBySession @@ -1133,7 +1133,7 @@ describe('background-task mirror', () => { }) it('stores an emptied set as an absent key so absence and [] read alike', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true) manager.handleMuxEnvelope(tasksFrame(S1, [])) @@ -1141,7 +1141,7 @@ describe('background-task mirror', () => { }) it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope({ rpcId: 's' as never, @@ -1151,7 +1151,7 @@ describe('background-task mirror', () => { }) it('drops the rows when the session is removed, whichever stream lands first', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) @@ -1159,7 +1159,7 @@ describe('background-task mirror', () => { }) it('notifies list subscribers so an open header re-renders without a poll', async () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) const seen = vi.fn() manager.subscribe(seen) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) diff --git a/packages/client/runtime/tests/partial.spec.ts b/packages/client/runtime/tests/partial.spec.ts index c1df190c33..328bd91ee4 100644 --- a/packages/client/runtime/tests/partial.spec.ts +++ b/packages/client/runtime/tests/partial.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest' -import type { StreamChunk } from '@deepseek-ai/dsh-client-connection/client' +import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client' import { PartialAccumulator } from '../src/client/sessions/partial.ts' const chunk = (c: Record): StreamChunk => c as unknown as StreamChunk diff --git a/packages/client/runtime/tests/projection-store.spec.ts b/packages/client/runtime/tests/projection-store.spec.ts index 5451b9e8b7..45b75e91a1 100644 --- a/packages/client/runtime/tests/projection-store.spec.ts +++ b/packages/client/runtime/tests/projection-store.spec.ts @@ -8,11 +8,11 @@ * list rows' title projection). */ 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 { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, ok } from './fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.ts' import { entries, plainTurn } from './event-script.ts' // Test-domain keys merged into the projection map (the Service Definition package's @@ -103,7 +103,7 @@ describe('ProjectionValueStore semantics', () => { describe('Session tail-page seeding', () => { it('seeds the store from a history response carrying a projections block', async () => { const api = new FakeApiClient() - const session = new Session(SID, api) + const session = new Session(SID, api, fakeRemote()) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, @@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => { it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() - const session = new Session(SID, api) + const session = new Session(SID, api, fakeRemote()) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, @@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async () => { const api = new FakeApiClient() - const session = new Session(SID, api) + const session = new Session(SID, api, fakeRemote()) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, 9) @@ -141,7 +141,7 @@ describe('manager frame routing', () => { it('lands session/projection frames before instantiation and the Session adopts the same store', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleMuxEnvelope({ rpcId: 'p1' as never, payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never, @@ -158,7 +158,7 @@ describe('manager frame routing', () => { it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) @@ -181,7 +181,7 @@ describe('manager frame routing', () => { it('projects every retained value into list rows with stable snapshot identity', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, @@ -211,7 +211,7 @@ describe('manager frame routing', () => { it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 1b6b721d25..d93b462d25 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -7,10 +7,10 @@ import { describe, expect, it } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient } from './fake-api.ts' +import { FakeApiClient, fakeRemote } from './fake-api.ts' const SID = 'fk-q1' as SessionId const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] @@ -42,7 +42,7 @@ function queueFrame(items: QueueFixture[]): MuxFrame { } function makeSession(): Session { - return new Session(SID, new FakeApiClient()) + return new Session(SID, new FakeApiClient(), fakeRemote()) } describe('queue snapshot intake', () => { @@ -198,7 +198,7 @@ describe('queue snapshot intake', () => { describe('queue operation transport', () => { it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { const api = new FakeApiClient() - const session = new Session(SID, api) + const session = new Session(SID, api, fakeRemote()) session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }])) const before = session.getSnapshot().queue @@ -251,14 +251,14 @@ describe('queue reconnect semantics', () => { describe('manager buffering of queue snapshots', () => { it('replays only the latest snapshot for an uninstantiated session', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) }) manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) }) expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) }) manager.handleMuxEnvelope({ rpcId: rid('g1b'), diff --git a/packages/client/runtime/tests/scope.spec.ts b/packages/client/runtime/tests/scope.spec.ts index 4c69b46edc..528c36131e 100644 --- a/packages/client/runtime/tests/scope.spec.ts +++ b/packages/client/runtime/tests/scope.spec.ts @@ -8,7 +8,7 @@ */ import { Context } from '@deepseek-ai/cordis' 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 { createScope, scopeOf } from '../src/client/agents/scope.ts' const sid = (k: string): SessionId => k as SessionId diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 4304f67684..43a78cffb2 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -9,7 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-commands/types' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { Session } from '../src/client/sessions/session.ts' import type { ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, @@ -17,7 +17,7 @@ import type { ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot, ConversationViewDefinition, } from '../src/client/index.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' import { entries, ev, plainTurn } from './event-script.ts' const SID = 'fk-s1' as SessionId @@ -159,7 +159,7 @@ const TEST_CONVERSATION: ConversationRuntime = { } function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) } + return { api, session: new Session(SID, api, fakeRemote(), { conversation: TEST_CONVERSATION }) } } function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] { @@ -343,7 +343,7 @@ describe('live event path', () => { entries: () => [testViewDefinition()], } as unknown as ConversationRuntime['views'], } - const session = new Session(SID, api, { conversation }) + const session = new Session(SID, api, fakeRemote(), { conversation }) await session.open() const snapshots: ConversationSnapshot[] = [] session.subscribe(() => { snapshots.push(session.getSnapshot()) }) @@ -448,7 +448,7 @@ describe('paging', () => { describe('prompt and cancel errors', () => { it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, { + const session = new Session(SID, api, fakeRemote(), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) @@ -487,7 +487,7 @@ describe('prompt and cancel errors', () => { api.onSubagentInterrupt = () => Promise.resolve(err({ code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, }) as never) - const session = new Session(SID, api, { + const session = new Session(SID, api, fakeRemote(), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) @@ -501,7 +501,7 @@ describe('prompt and cancel errors', () => { it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, { + const session = new Session(SID, api, fakeRemote(), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, }) await session.open() diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 757376b616..956be93581 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -8,9 +8,9 @@ */ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' const sid = (s: string): SessionId => s as SessionId @@ -23,7 +23,7 @@ interface Bench { function bench(): Bench { const ctx = new Context() const api = new FakeApiClient() - const svc = new SessionsService(ctx, api) + const svc = new SessionsService(ctx, api, fakeRemote()) return { ctx, api, svc } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index a52eb4e3e9..73861b8003 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -6,7 +6,7 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' // Type-only: the api-remotes facade carries both the allowlist's selection seat // and the owner packages' `./types` declarations, which together give `$on` its diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index aa0f404da6..d0adfd3a7f 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -1,10 +1,10 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId const wid = (id: string): WorkspaceId => id as WorkspaceId @@ -124,7 +124,7 @@ describe('WorkspacesService', () => { it('feeds readiness and recent-Workspace targeting without changing Host order', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ items: [ @@ -152,7 +152,7 @@ describe('WorkspacesService', () => { it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[], @@ -211,7 +211,7 @@ describe('WorkspacesService', () => { it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] })) api.onList = () => Promise.resolve(ok({ @@ -231,7 +231,7 @@ describe('WorkspacesService', () => { it('returns created Workspaces and preserves Host business errors', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true, @@ -250,7 +250,7 @@ describe('WorkspacesService', () => { it('passes native directory selection and cancellation through without local state', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha') @@ -264,7 +264,7 @@ describe('WorkspacesService', () => { it('passes listings and creation through the browse wire, wrapping business failures', async () => { const ctx = new Context() const api = new FakeApiClient() - const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api)) + const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote())) const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false } api.onListDirectory = () => Promise.resolve(ok(listing)) await expect(workspaces.listDirectory()).resolves.toEqual(listing) @@ -285,7 +285,7 @@ describe('WorkspacesService', () => { it('opens a filesystem path through the host without local state', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined() expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }]) @@ -296,7 +296,7 @@ describe('WorkspacesService', () => { it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) await workspaces.refresh() @@ -312,7 +312,7 @@ describe('WorkspacesService', () => { it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onList = () => Promise.resolve(ok({ items: [ @@ -358,7 +358,7 @@ describe('WorkspacesService', () => { it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }], @@ -392,7 +392,7 @@ describe('startInitialSelection', () => { function bench() { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) return { api, sessions, workspaces } } diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 6641467cc2..ce682b81f9 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../web-react" }, - { - "path": "../connection" - }, { "path": "../../host/apiproxy" }, @@ -60,7 +57,7 @@ "path": "../../typert/registry" }, { - "path": "../../api/gateway" + "path": "../../api/remotes/tsconfig.client.json" } ], "exclude": [ diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 6a58ff6603..30786bbb45 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -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:^", diff --git a/packages/client/ui-command/src/client/directory.ts b/packages/client/ui-command/src/client/directory.ts index 0e4fd916b2..3e6a9cf2eb 100644 --- a/packages/client/ui-command/src/client/directory.ts +++ b/packages/client/ui-command/src/client/directory.ts @@ -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>['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; diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index 765e14f751..6f9dc56c94 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -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 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 86a7550cef..056067f46f 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -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 { - 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' } } diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index af60a45e16..b413e8d79e 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -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 () => { diff --git a/packages/client/ui-command/tests/directory.spec.ts b/packages/client/ui-command/tests/directory.spec.ts index c1c0b5a75d..3f0b1b91df 100644 --- a/packages/client/ui-command/tests/directory.spec.ts +++ b/packages/client/ui-command/tests/directory.spec.ts @@ -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' diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index b2e5826cdd..861bcdc0aa 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -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() 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 => 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 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 () => { diff --git a/packages/client/ui-command/tsconfig.json b/packages/client/ui-command/tsconfig.json index d87e874d01..255f8229b0 100644 --- a/packages/client/ui-command/tsconfig.json +++ b/packages/client/ui-command/tsconfig.json @@ -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" }, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 1f9642e236..1eabde8cb8 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -16,7 +16,7 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' -import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.ts' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { SessionInputShell } from '../src/client/input/facade.ts' @@ -98,7 +98,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { api.onList = () => Promise.resolve(ok({ items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }], }) as never) - const sessions = new SessionsService(ctx, api) // provides 'sessions' itself + const sessions = new SessionsService(ctx, api, fakeRemote()) // provides 'sessions' itself await sessions.refresh() await Promise.resolve() // manager notifier flush await ctx.plugin(SlashService).await() diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 58af4f794f..0ef0bd8a19 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../connection" - }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 0a8f1512bf..4a60b9c272 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -45,7 +45,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -57,7 +57,7 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 746c34a4e8..4fe7bd3e37 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -7,7 +7,7 @@ * projection pair through the standard-kit `useProjection`; zero client-side * plan state. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -39,8 +39,8 @@ export interface PlanChipInjected { exitPlanMode: () => Promise } -/** Required services: the seat's slot registry, transport, and locale registry. */ -export const inject = ['slots', 'connection', 'locale'] +/** Required services: the seat's slot registry, commands Remote, and locale registry. */ +export const inject = ['slots', 'remote', 'remote.commands', 'locale'] /** * Client plugin body: register the plan chip over the command channel. @@ -55,10 +55,9 @@ export function apply(ctx: ClientContext): void { inject: (sessionId: SessionId): PlanChipInjected => ({ // Failure strings stay English (error-surface policy: not localized). exitPlanMode: async () => { - const connection = ctx.get('connection') as ConnectionHandle - const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' }) + const result = await ctx.remote.commands.execute(sessionId, '/plan off') if (!result.ok) return `${result.error.message} (${result.error.code})` - if (!result.value.matched) return 'unknown command: /plan off' + if (result.value === undefined) return 'unknown command: /plan off' return null }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 79d2903e93..1a412c06de 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -25,16 +25,18 @@ async function bench() { name: 'root', children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } }, } as never, () => null) - const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) => - Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } })) - ctx.provide('connection', { api: { commands: { execute } } }) + const execute = vi.fn((_sessionId: SessionId, _line: string) => + Promise.resolve({ commandId: 'c1', result: { kind: 'success' as const } })) + const commandsRemote = { execute } + ctx.provide('remote', { commands: commandsRemote }) + ctx.provide('remote.commands', commandsRemote) ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots, execute } } describe('ui-plan browser apply', () => { it('declares every service it binds', () => { - expect(inject).toEqual(['slots', 'connection', 'locale']) + expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'locale']) }) it('node-half apply is an intentional no-op', () => { @@ -44,7 +46,8 @@ describe('ui-plan browser apply', () => { it('waits until conversation declares the plan seat', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() - ctx.provide('connection', {}) + ctx.provide('remote', { commands: {} }) + ctx.provide('remote.commands', {}) ctx.provide('locale', new LocaleService(ctx)) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -65,18 +68,17 @@ describe('ui-plan browser apply', () => { const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) await expect(injected.exitPlanMode()).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) + expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off') - // Business failure folds to the composer-visible line. - b.execute.mockResolvedValueOnce({ - result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, - } as never) + // Business failure folds to the composer-visible line: the generated method + // throws with the RPC failure as its cause. + b.execute.mockRejectedValueOnce(new Error('client api: commands/execute failed', { + cause: { code: 'session-not-found', message: 'gone', details: {} }, + })) await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. - b.execute.mockResolvedValueOnce({ - result: { ok: true as const, value: { matched: false as const } }, - } as never) + b.execute.mockResolvedValueOnce(undefined as never) await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off') await fiber.dispose() diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 0772c23b31..c9f65c0c36 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -8,15 +8,15 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, { "path": "../runtime" }, - { - "path": "../connection" - }, { "path": "../locale" }, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8bca4156a3..5a22398aa9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -67,7 +67,7 @@ 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')`. +// Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the @@ -2889,49 +2889,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - commands: { - // Both methods address one session's agent. agentFor resumes on miss - // and fences every subagent-owned identity with `agent-busy`; the - // api/commands.ts module contract owns that fence's wording, so this - // comment only notes the routing shape: clients send a sessionId for a - // published session, and resume restores an existing entity. - async list(request) { - // Missing service = the deployment omitted dsh-commands from its - // composition, not an empty catalog: fail loud instead of serving []. - const commands = ctx.get('commands') - if (commands === undefined) { - return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const found = await agentFor(request.payload.sessionId) - if ('error' in found) return err(request, found.error) - return ok(request, { commands: commands.list(found.agent) }) - }, - - async execute(request, signal) { - const commands = ctx.get('commands') - if (commands === undefined) { - return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const { sessionId, line } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - try { - // Pure admission: the executor's durable command/run + command/done - // pair (broadcast on the mux stream) carries the outcome; the - // response reports whether the line resolved to a handler, plus the - // minted pairing id so the issuing client can correlate its request - // with the flow node the lifecycle events produce. - const execution = await commands.execute(found.agent, line, signal) - return ok(request, execution === undefined - ? { matched: false } - : { matched: true, commandId: execution.commandId }) - } catch (error: unknown) { - if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} }) - return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} }) - } - }, - }, - goals: { // Mutations only — the read side is the 'goal' session projection. // Every verb resolves the session's agent (agentFor: implicit cold diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts deleted file mode 100644 index c135c82e5a..0000000000 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * commands domain zod schemas (names derived from map keys: commandListRequestSchema / - * commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema). - */ - -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' -import type { CommandDescriptor } from './commands.ts' - -/** CommandDescriptor row of command.list. */ -export const commandDescriptorSchema = z.object({ - name: z.string().min(1), - description: z.string(), - input: z.object({ hint: z.string() }).optional(), -}) satisfies z.ZodType> - -/** command.list request payload. */ -export const commandListRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** command.list response value. */ -export const commandListValueSchema = z.object({ - commands: z.array(commandDescriptorSchema), -}) satisfies z.ZodType>> - -/** command.execute request payload. */ -export const commandExecuteRequestSchema = z.object({ - sessionId: sessionIdSchema, - line: z.string(), -}) satisfies z.ZodType>> - -/** CommandId: one brand cast after schema 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: commandIdSchema.optional(), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts deleted file mode 100644 index 984a184093..0000000000 --- a/packages/host/apiproxy/src/api/commands.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * commands domain contract: the web catalog/dispatch face of the host command - * registry (`ctx.commands`). Both methods address an ordinary session's Agent - * via `sessionId`, resuming it when cold. Session-backed subagents reject with - * `agent-busy` and retain their dedicated continuation owner. - */ - -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' - -/** - * Handler-free command view served to clients. Wire mirror of the host - * registry descriptor (which stays host-side with its cordis dependencies); - * no source field — the host descriptor has none. - */ -export interface CommandDescriptor { - /** Lowercase command name without the leading slash. */ - readonly name: string - /** Human-readable summary used in discovery UI. */ - readonly description: string - /** Optional free-form input hint advertised to capable clients. */ - readonly input?: { readonly hint: string } -} - -/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */ -export interface CommandsApi { - /** - * Lists the addressed agent's effective command catalog (name-sorted, - * globals plus its scoped shadows). Session-backed subagents reject with - * `agent-busy`. - */ - list(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Parses and executes one slash-command line against the addressed agent - * without sending it to the model — pure admission semantics. matched=false - * when syntax or name does not resolve (the client falls back to its - * default sink). The handler's outcome does NOT ride the response: the host - * executor durably logs the lifecycle (`command/run`/`command/done`), which - * broadcasts on the mux stream and renders as a persistent flow node. - * `commandId` is present exactly when matched — the minted lifecycle - * pairing id, letting the issuing client correlate this acknowledgment - * with that flow node. The signal rides beside the request, never on the - * wire: the fetch carrier's request signal cancels the running handler. - * Session-backed subagents reject with `agent-busy` before dispatch. - */ - execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal): - Promise> -} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 5f72192969..7c7b43d650 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -7,7 +7,6 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' -import type { CommandsApi } from './commands.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' @@ -25,7 +24,6 @@ export interface ApiProxy { subagents: SubagentsApi host: HostApi workspace: WorkspaceApi - commands: CommandsApi skills: SkillsApi agentPresets: AgentPresetsApi events: EventsApi @@ -52,7 +50,6 @@ export type { } from './subagents.ts' export type { TaskView } from './tasks.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' -export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index ca7231e774..f81ac842af 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -7,7 +7,6 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' -import type { CommandsApi } from './commands.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' @@ -50,8 +49,6 @@ export interface RpcMethodMap { 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] - 'command.list': CommandsApi['list'] - 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] 'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.select': AgentPresetsApi['select'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 3c74ad04ac..e4b6a2bed6 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -39,7 +39,6 @@ import { workspaceListValueSchema, workspaceRenameValueSchema, } from '../api/workspace.schema.ts' -import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -120,10 +119,6 @@ export interface IApiClient { insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } - commands: { - list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>> - execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise>> - } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } @@ -200,8 +195,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.archiveSession', payload, signal), } - readonly commands: IApiClient['commands'] = { - list: (payload, signal) => this.callUnary('command.list', payload, signal), - // Command handlers are user-driven operations and may legitimately exceed - // the transport health deadline. Caller/connection aborts remain. - execute: (payload, signal) => this.callUnary( - 'command.execute', payload, signal, 'caller-signal-only', - ), - } - readonly skills: IApiClient['skills'] = { list: (payload, signal) => this.callUnary('skill.list', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1e902f059e..1361bb9b1f 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -42,7 +42,6 @@ import { workspaceListRequestSchema, workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' -import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, @@ -115,8 +114,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, - '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) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index ca0cf0329b..43b4c5d5b0 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -76,7 +76,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly subagents: ApiProxy['subagents'] readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] - readonly commands: ApiProxy['commands'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] readonly agentPresets: ApiProxy['agentPresets'] @@ -102,7 +101,6 @@ export class ApiProxyService extends Service implements ApiProxy { this.subagents = api.subagents this.workspace = api.workspace this.host = api.host - this.commands = api.commands this.goals = api.goals this.skills = api.skills this.agentPresets = api.agentPresets diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts deleted file mode 100644 index 9abe116d31..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' -/** - * Command/skill RPC handlers and the two new frames over createApiProxy: - * command.list serves the addressed agent's effective catalog (missing - * registry = loud internal error), command.execute dispatches through the - * registry with the carrier signal, skill.list resolves cwd from the session - * header (never via the Agent registry), the host stream broadcasts - * commands-changed, and the mux stream carries live queued frames plus the - * open-time queue snapshot. - */ - -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import CommandService from '@deepseek-ai/dsh-commands' -import SkillService from '@deepseek-ai/dsh-skill' -import type { HostFrame } from '../src/api/index.ts' -import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' -import { RpcId } from '../src/api/rpc.ts' -import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts' - -const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } - -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } -} -let nextRpc = 1 - -function expectOk(response: RpcResponse): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -function expectErr(response: RpcResponse): { code: string; message: string } { - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - return response.result.error -} - -/** Composition floor for the command/skill paths (no LLM, no persistence). */ -async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - if (options.skills !== false) await ctx.plugin(SkillService, {}) - if (options.commands !== false) await ctx.plugin(CommandService) - // Host-stream opener reads the committed-workspace baseline; the stub - // suffices here — the real workspace composition is api-proxy-workspace.spec's. - ctx.provide('workspace', { list: () => [] } as never) - return ctx -} - -/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */ -function stubAgent(ctx: Context, sessionId?: SessionId): Agent { - const session = ctx.sessions.create(sessionId) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const agent = { - id: session.id, - session, - inbox, - status: 'idle', - ctx, - } as Agent - ctx.agents.register(agent) - return agent -} - -/** Drain `count` frames from a stream, then abort it. */ -async function collect(iterable: AsyncIterable>, count: number, abort: AbortController): Promise { - const frames: F[] = [] - for await (const frame of iterable) { - frames.push(frame.payload) - if (frames.length >= count) abort.abort() - } - return frames -} - -/** Read the next payload from an open stream. */ -async function nextFrame(iterator: AsyncIterator>): Promise { - const result = await iterator.next() - if (result.done) throw new Error('stream ended') - return result.value.payload -} - -describe('command.list', () => { - it('serves the addressed agent\'s name-sorted catalog', async () => { - const ctx = await harness() - ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) }) - ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '' }, handler: () => ({ kind: 'success' }) }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const value = expectOk(await api.commands.list(request({ sessionId: agent.id }))) - expect(value.commands).toEqual([ - { name: 'alpha', description: 'a', input: { hint: '' } }, - { name: 'zeta', description: 'z' }, - ]) - }) - - it('fails loud with internal when the command registry is not mounted', async () => { - const ctx = await harness({ commands: false }) - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('command registry') - }) -}) - -describe('command.execute', () => { - it('executes a known command against the addressed agent and detaches the result', async () => { - const ctx = await harness() - let received: string | undefined - ctx.commands.register({ - name: 'goal', - description: 'set goal', - handler: (invocation) => { - received = invocation.rawInput - return { kind: 'success', text: `goal:${invocation.agent.id}` } - }, - }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal)) - expect(value).toMatchObject({ matched: true }) - expect(value.commandId).toBeTruthy() - expect(received).toBe(' ship it') - // Pure admission on the wire: the outcome rides the durably logged - // lifecycle pair instead of the response. - const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done') - expect(lifecycle).toMatchObject([ - { type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } }, - { type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } }, - ]) - }) - - it('returns matched:false when syntax or name does not resolve', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const signal = new AbortController().signal - expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false }) - expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false }) - }) - - it('maps a session miss to session-not-found and a registry gap to internal', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const missing = expectErr(await api.commands.execute( - request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal)) - expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate - - const bare = await harness({ commands: false }) - const bareApi = createApiProxy(bare, DEFAULTS) - expect(expectErr(await bareApi.commands.execute( - request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal') - }) - - it('reports an aborted handler as cancelled and a throwing handler as internal', async () => { - const ctx = await harness() - ctx.commands.register({ - name: 'hang', - description: 'never settles on its own', - handler: () => new Promise(() => { /* settled only by abort */ }), - }) - ctx.commands.register({ - name: 'boom', - description: 'throws', - handler: () => { throw new Error('kaboom') }, - }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - - const controller = new AbortController() - const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal) - controller.abort() - expect(expectErr(await pending).code).toBe('cancelled') - - const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal)) - expect(thrown.code).toBe('internal') - expect(thrown.message).toContain('kaboom') - }) -}) - -describe('skill.list', () => { - it('lists skills for the session cwd taken from the header', async () => { - const ctx = await harness() - const seenCwds: (string | undefined)[] = [] - ctx.skills.registerProvider(() => ({ - name: 'probe', - list: (options) => { - seenCwds.push(options.cwd) - return Promise.resolve([ - { - name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'user-only', description: 'User-only', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'model-only', description: 'Model-only', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'trusted-only', description: 'Trusted-only', - invocation: { modelInvocable: false, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - ]) - }, - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - // No agent is registered for this session: header resolution must not - // touch (or resume through) the Agent registry. - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const value = expectOk(await api.skills.list(request({ sessionId: session.id }))) - expect(value.skills).toEqual([ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, - { name: 'user-only', description: 'User-only', modelInvocable: false }, - ]) - expect(seenCwds).toEqual(['/proj']) - expect(ctx.agents.get(session.id)).toBeUndefined() - }) - - it('fails loud on an unattached session id (business error, no resume attempt)', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId }))) - expect(error.code).toBe('session-not-found') - }) - - it('fails loud with internal when the skill registry is not mounted', async () => { - const ctx = await harness({ skills: false }) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const error = expectErr(await api.skills.list(request({ sessionId: session.id }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill registry is absent') - }) - - it('folds a provider failure into internal', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'broken', - list: () => Promise.reject(new Error('directory exploded')), - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const response = await api.skills.list(request({ sessionId: session.id })) - // dsh-skill contains one provider's failure (logs and serves the rest), so - // this surfaces as an empty ok catalog rather than an error. - const value = expectOk(response) - expect(value.skills).toEqual([]) - }) -}) - -describe('forwarded commands/change frame', () => { - it('broadcasts on registry change', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const abort = new AbortController() - const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal) - const collected = collect(stream, 1, abort) - ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) }) - // Verbatim forwarding: the wire name is the host's own event name and - // `args` is its argument list (empty for this pure invalidation). - expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }]) - }) - - // The guard belongs to the forwarding boundary, so it is tested there rather - // than through a malformed `ctx.emit`: every currently allowlisted event has a - // statically JSON-safe payload, so no type-legal emit can reach the rejection - // branch. These cases stand in for a future allowlist entry whose payload the - // wire cannot carry — a composition mistake that must fail loud. - describe('assertJsonArgs', () => { - it('passes a JSON-safe argument list through unchanged', () => { - const args = ['llm-deepseek', 7, null, { nested: ['ok'] }] - expect(assertJsonArgs('settings/document-updated', args)).toEqual(args) - expect(assertJsonArgs('commands/change', [])).toEqual([]) - }) - - it('names the offending event and argument position when a payload is not lossless JSON', () => { - expect(() => assertJsonArgs('credentials/updated', [1n])) - .toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data') - expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}])) - .toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data') - }) - }) -}) - -/** Build one frozen inbox message. */ -function inboxMessage(id: string, text: string, rpcId?: string): UserMessage { - return freezeMessage({ - id: MessageId(id), - role: 'user', - content: [{ type: 'text' as const, text }], - source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) }, - }) -} - -describe('session.updateQueue', () => { - it('splices a queued message and reports a lost claim race', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const present = inboxMessage('present', 'before') - agent.inbox.splice('next-turn', 0, 0, [present]) - const api = createApiProxy(ctx, DEFAULTS) - - const applied = await api.sessions.updateQueue({ - rpcId: RpcId('q-apply'), - payload: { - sessionId: agent.id, - itemId: MessageId('present'), - action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, - }, - }) - expect(expectOk(applied)).toEqual({ accepted: true }) - const missing = await api.sessions.updateQueue({ - rpcId: RpcId('q-missing'), - payload: { - sessionId: agent.id, - itemId: MessageId('claimed'), - action: { kind: 'remove' }, - }, - }) - expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) - expect(agent.inbox.nextTurn[0]).toMatchObject({ - id: 'present', - content: [{ type: 'text', text: 'edited' }], - }) - }) - - it('rejects a stale occurrence without resuming a cold agent', async () => { - const ctx = await harness() - const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, DEFAULTS) - const response = await api.sessions.updateQueue({ - rpcId: RpcId('q-cold'), - payload: { - sessionId: 'cold-session' as SessionId, - itemId: MessageId('stale-item'), - action: { kind: 'remove' }, - }, - }) - - expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' }) - expect(resume).not.toHaveBeenCalled() - }) -}) - -describe('session/queue frames', () => { - it('publishes authoritative inbox snapshots without duplicating message identity', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const queued = inboxMessage('m-1', 'queued prompt') - const edited = inboxMessage('m-1', 'edited prompt') - const steering = inboxMessage('m-2', 'steering prompt') - agent.inbox.splice('next-turn', 0, 0, [queued]) - agent.inbox.splice('next-step', 0, 0, [steering]) - - const abort = new AbortController() - const iterator = api.events.mux({ - rpcId: RpcId('t-mux-baseline'), - payload: {}, - }, abort.signal)[Symbol.asyncIterator]() - const frames = [ - await nextFrame(iterator), - await nextFrame(iterator), - ] - agent.inbox.splice('next-turn', 0, 1, [edited]) - frames.push(await nextFrame(iterator), await nextFrame(iterator)) - const injected = freezeMessage({ - id: MessageId('m-3'), - role: 'user', - content: [{ type: 'text' as const, text: 'injected context' }], - source: { kind: 'plugin' as const, plugin: 'approval' }, - }) - agent.inbox.splice('next-step', 0, 0, [injected]) - frames.push(await nextFrame(iterator), await nextFrame(iterator)) - abort.abort() - await iterator.return?.() - - expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([ - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: queued.id, placement: 'queued', message: queued }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: edited.id, placement: 'queued', message: edited }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: edited.id, placement: 'queued', message: edited }, - { id: injected.id, placement: 'context', message: injected }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - ]) - }) -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index fdee096348..15888b3cfb 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -21,7 +21,6 @@ function scriptedApi(overrides: { sessions?: Partial subagents?: Partial host?: Partial - commands?: Partial skills?: Partial agentPresets?: Partial events?: Partial @@ -89,11 +88,6 @@ function scriptedApi(overrides: { insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, - commands: { - list: r => ok(r, { commands: [] }), - execute: r => ok(r, { matched: false }), - ...overrides.commands, - }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, agentPresets: { list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 1888cf602a..5ee9b54060 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,4 +1,3 @@ -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' @@ -190,25 +189,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } }, }, - commands: { - async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } } - }, - async execute(request, signal) { - if (request.payload.line === '/hang') { - // Cooperative hang: settles only through the carrier signal (sticky - // abort checked first — listeners never fire retroactively). - if (!signal.aborted) { - await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - } - 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: CommandId('cmd-x') } } } - } - return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } - }, - }, agentPresets: { list(request: RpcRequest<{}>) { return Promise.resolve({ @@ -441,19 +421,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { opened: true } }) }) - it('round-trips command.list / command.execute / skill.list through the wire form', async () => { + it('round-trips skill.list through the wire form', async () => { const c = client() - const list = await c.commands.list({ sessionId: 's' as never }) - expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } }) - const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' }) - expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } }) - const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) - expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) }) - it('lets command.execute finish after the 30-second default unary deadline', async () => { + it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => { vi.useFakeTimers() const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { const controller = new AbortController() @@ -464,16 +438,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) try { const api = fakeApi() - api.commands.execute = async (request) => { + api.host.pickDirectory = async (request) => { await new Promise(resolve => setTimeout(resolve, 30_001)) - return { - rpcId: request.rpcId, - result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } }, - } + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } } } - const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' }) + const execution = client(api).host.pickDirectory({}) const assertion = expect(execution).resolves.toMatchObject({ - result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } }, + result: { ok: true, value: { path: '/tmp/slow' } }, }) await Promise.all([ @@ -509,10 +480,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { })).result).toEqual({ ok: true, value: { accepted: true } }) }) - it('keeps caller and connection aborts on command.execute', async () => { + it('keeps caller and connection aborts on a deadline-exempt unary', async () => { const api = fakeApi() const started = Promise.withResolvers() - api.commands.execute = async (request, signal) => { + api.host.pickDirectory = async (request, signal) => { started.resolve(signal) if (!signal.aborted) { await new Promise((resolve) => { @@ -525,10 +496,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } } const controller = new AbortController() - const execution = client(api).commands.execute( - { sessionId: 's' as never, line: '/hang' }, - controller.signal, - ) + const execution = client(api).host.pickDirectory({}, controller.signal) const handlerSignal = await started.promise controller.abort(new Error('connection closed')) diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 3865fbd2ae..1b377bebce 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -30,6 +30,14 @@ "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -37,7 +45,12 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -46,6 +59,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { @@ -54,6 +68,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 56b5d4da14..fb281cc543 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -3,26 +3,27 @@ * @module @deepseek-ai/dsh-commands */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' 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 { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' import { CommandId } from './brand.ts' +import type { + CommandDescriptor, + CommandExecution, + CommandInputDescriptor, + CommandResult, +} from './types.ts' export { CommandId } from './brand.ts' -export type { CommandSource, CommandSourceMap } from './types.ts' +export type * from './types.ts' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** Immutable metadata for a command's optional unstructured input. */ -export interface CommandInputDescriptor { - /** Placeholder shown before the user supplies free-form input. */ - readonly hint: string -} - /** Invocation passed to one registered command handler. */ export interface CommandInvocation { /** Pairing id already written to this invocation's `command/run` event. */ @@ -35,29 +36,6 @@ export interface CommandInvocation { readonly signal: AbortSignal } -/** Expected command outcome rendered directly by the dispatching UI. */ -export type CommandResult = - | { - readonly kind: 'success' - readonly text?: string - /** Earlier authoritative domain event that owns a richer presentation. */ - readonly sourceEventSeq?: number - } - | { readonly kind: 'error'; readonly text: string } - -/** - * One settled command execution: the handler's normalized result plus the - * lifecycle pairing id minted for its `command/run`/`command/done` records, - * so a dispatching surface can correlate the RPC-level acknowledgment with - * the flow node those events produce. - */ -export interface CommandExecution { - /** Pairing id carried by this execution's lifecycle events. */ - readonly commandId: CommandId - /** The handler's normalized outcome. */ - readonly result: CommandResult -} - /** Plugin-owned command registration. */ export interface CommandDefinition { /** Lowercase command name without the leading slash. */ @@ -76,16 +54,6 @@ export interface CommandDefinition { readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } -/** Handler-free immutable command view returned to UI adapters. */ -export interface CommandDescriptor { - /** Lowercase command name without the leading slash. */ - readonly name: string - /** Human-readable summary used in discovery UI. */ - readonly description: string - /** Optional free-form input hint advertised to capable clients. */ - readonly input?: CommandInputDescriptor -} - /** Syntactically valid slash command before registry resolution. */ export interface ParsedCommand { /** Lowercase command name without the leading slash. */ @@ -254,7 +222,7 @@ function normalizeResult(command: string, value: unknown): CommandResult { * registered through a command-injected child of an agent context shadow * globals for that agent. */ -export class CommandService extends Service { +export class CommandService extends GatewayService { private readonly layers = new ScopedLayers( scope => new CommandLayer(scope), () => { this.notifyChange() }, @@ -288,6 +256,7 @@ export class CommandService extends Service { * @param agent - exact receiving agent and scoped-layer key. * @returns name-sorted descriptors after scoped shadowing. */ + @Remote list(agent: Agent): readonly CommandDescriptor[] { return Object.freeze([...this.view(agent).values()] .map(command => command.descriptor) @@ -324,6 +293,7 @@ export class CommandService extends Service { * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ + @Remote async execute( agent: Agent, line: string, diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 27309355d4..32f1dbcc43 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -9,6 +9,45 @@ import type { CommandId } from './brand.ts' +/** Immutable metadata for a command's optional unstructured input. */ +export interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} + +/** Expected command outcome rendered directly by the dispatching UI. */ +export type CommandResult = + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } + | { readonly kind: 'error'; readonly text: string } + +/** + * One settled command execution: the handler's normalized result plus the + * lifecycle pairing id minted for its `command/run`/`command/done` records, + * so a dispatching surface can correlate the Remote acknowledgment with the + * flow node those events produce. + */ +export interface CommandExecution { + /** Pairing id carried by this execution's lifecycle events. */ + readonly commandId: CommandId + /** The handler's normalized outcome. */ + readonly result: CommandResult +} + +/** Handler-free immutable command view returned to UI adapters. */ +export interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor +} + /** * Producer record for one command invocation (the `command/run` event's * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s diff --git a/packages/interaction/commands/tsconfig.json b/packages/interaction/commands/tsconfig.json index 901c76a377..128d7d9147 100644 --- a/packages/interaction/commands/tsconfig.json +++ b/packages/interaction/commands/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d63ee5fa71..62e3dc5898 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1566,6 +1566,12 @@ importers: '@deepseek-ai/dsh-client-ui-deliverables': specifier: workspace:^ version: link:../../client/ui-deliverables + '@deepseek-ai/dsh-client-ui-directory-picker': + specifier: workspace:^ + version: link:../../client/ui-directory-picker + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../client/ui-directory-picker-native '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../client/ui-goal @@ -1862,9 +1868,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../../api/gateway + version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2049,6 +2055,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2190,6 +2199,82 @@ importers: specifier: ~18.3.1 version: 18.3.31 + packages/client/ui-directory-picker: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-directory-picker-native: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-goal: devDependencies: '@deepseek-ai/cordis': @@ -2417,9 +2502,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2606,6 +2691,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2630,9 +2718,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': - specifier: workspace:^ - version: link:../../api/gateway '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -2988,6 +3073,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -4777,6 +4865,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-client-ui-directory-picker': + specifier: workspace:^ + version: link:../../client/ui-directory-picker + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../client/ui-directory-picker-native '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker @@ -4801,40 +4895,13 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../client/test-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../../client/ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/host/directory-picker-native: dependencies: @@ -4851,24 +4918,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 tsx: specifier: ^4.19.2 version: 4.22.4 @@ -4925,6 +4977,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta packages/interaction/permission: dependencies: