Merge remote-tracking branch 'origin/master' into dshw/pr-2250
# Conflicts: # packages/client/connection/tests/fake-api.client.ts # packages/client/runtime/tests/manager.client.spec.ts # packages/client/runtime/tests/workspaces-service.client.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts
This commit is contained in:
@@ -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
|
||||
@@ -2903,49 +2903,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
|
||||
|
||||
@@ -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<Wire<CommandDescriptor>>
|
||||
|
||||
/** command.list request payload. */
|
||||
export const commandListRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.list'>>>
|
||||
|
||||
/** command.list response value. */
|
||||
export const commandListValueSchema = z.object({
|
||||
commands: z.array(commandDescriptorSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.list'>>>
|
||||
|
||||
/** command.execute request payload. */
|
||||
export const commandExecuteRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
line: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
|
||||
|
||||
/** 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<CommandId>
|
||||
|
||||
/** 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<Wire<ResponseValue<'command.execute'>>>
|
||||
@@ -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<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
|
||||
|
||||
/**
|
||||
* 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<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -51,8 +50,6 @@ export interface RpcMethodMap {
|
||||
'workspace.insertBefore': WorkspaceApi['insertBefore']
|
||||
'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']
|
||||
|
||||
@@ -91,6 +91,9 @@ export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.
|
||||
}
|
||||
|
||||
// ---- The four wire full-form schemas (payload/result.value slots stay wide — business layer does the second parse) ----
|
||||
// The wide value slot is optional: a void business result serializes with no
|
||||
// `value` field at all. Each endpoint's own second parse still requires its
|
||||
// declared value, so absence never passes for a method that returns data.
|
||||
|
||||
/** ClientRequest full form (payload stays wide — the business layer runs the second parse). */
|
||||
export const clientRequestSchema = z.object({
|
||||
@@ -104,7 +107,7 @@ export const clientRequestSchema = z.object({
|
||||
export const serverResponseSchema = z.object({
|
||||
type: z.literal('server-response'),
|
||||
rpcId: rpcIdSchema,
|
||||
result: rpcResultSchema(z.unknown()),
|
||||
result: rpcResultSchema(z.unknown().optional()),
|
||||
}) as unknown as z.ZodType<ServerResponse>
|
||||
|
||||
/** ServerRequest full form (payload stays wide). */
|
||||
@@ -119,7 +122,7 @@ export const serverRequestSchema = z.object({
|
||||
export const clientResponseSchema = z.object({
|
||||
type: z.literal('client-response'),
|
||||
rpcId: rpcIdSchema,
|
||||
result: rpcResultSchema(z.unknown()),
|
||||
result: rpcResultSchema(z.unknown().optional()),
|
||||
}) as unknown as z.ZodType<ClientResponse>
|
||||
|
||||
/** Wire full-form union (discriminated by type). */
|
||||
|
||||
@@ -40,7 +40,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,
|
||||
@@ -122,10 +121,6 @@ export interface IApiClient {
|
||||
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>>
|
||||
}
|
||||
commands: {
|
||||
list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.list'>>>
|
||||
execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.execute'>>>
|
||||
}
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
}
|
||||
@@ -203,8 +198,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'workspace.insertBefore': workspaceInsertBeforeValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
'workspace.archiveSession': workspaceArchiveSessionValueSchema,
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'agentPreset.list': agentPresetListValueSchema,
|
||||
'agentPreset.select': agentPresetSelectValueSchema,
|
||||
@@ -460,15 +453,6 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
archiveSession: (payload, signal) => 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),
|
||||
}
|
||||
|
||||
@@ -43,7 +43,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,
|
||||
@@ -117,8 +116,6 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(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) },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
let nextRpc = 1
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
function expectErr<T>(response: RpcResponse<T>): { 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<Context> {
|
||||
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<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, abort: AbortController): Promise<F[]> {
|
||||
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<F>(iterator: AsyncIterator<RpcRequest<F>>): Promise<F> {
|
||||
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: '<x>' }, 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: '<x>' } },
|
||||
{ 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<HostFrame>(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 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,6 @@ function scriptedApi(overrides: {
|
||||
sessions?: Partial<ApiProxy['sessions']>
|
||||
subagents?: Partial<ApiProxy['subagents']>
|
||||
host?: Partial<ApiProxy['host']>
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
agentPresets?: Partial<ApiProxy['agentPresets']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
@@ -90,11 +89,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 }),
|
||||
|
||||
@@ -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'
|
||||
@@ -193,25 +192,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<void>((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({
|
||||
@@ -444,19 +424,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()
|
||||
@@ -467,16 +441,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([
|
||||
@@ -512,10 +483,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<AbortSignal>()
|
||||
api.commands.execute = async (request, signal) => {
|
||||
api.host.pickDirectory = async (request, signal) => {
|
||||
started.resolve(signal)
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -528,10 +499,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'))
|
||||
@@ -540,20 +508,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(handlerSignal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into command.execute', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
|
||||
// The fake's /hang settles only when the invoke-level signal aborts: a
|
||||
// completed response with the cancelled error proves req.signal reached it.
|
||||
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
|
||||
expect(parsed.rpcId).toBe('r-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into session.search', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -28,10 +28,6 @@ import {
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
} from '../src/api/workspace.schema.ts'
|
||||
import {
|
||||
commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema,
|
||||
commandListRequestSchema, commandListValueSchema,
|
||||
} from '../src/api/commands.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
|
||||
@@ -81,6 +77,8 @@ describe('rpcErrorSchema', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
||||
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
|
||||
// The credentials producer still emits this code, so the branch has to stay.
|
||||
expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected')
|
||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||
})
|
||||
|
||||
@@ -116,9 +114,14 @@ describe('wire full-form schemas', () => {
|
||||
expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a quadrant missing its members', () => {
|
||||
it('rejects a quadrant missing its members but accepts a valueless success result', () => {
|
||||
expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
|
||||
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow()
|
||||
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow()
|
||||
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow()
|
||||
// A void business result carries no value field; the endpoint's own second
|
||||
// parse is what requires a value for methods that return data.
|
||||
expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId)
|
||||
.toBe('r1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -365,6 +368,13 @@ describe('workspace domain schemas', () => {
|
||||
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
|
||||
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
|
||||
it('create requires a path', () => {
|
||||
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
|
||||
expect(() => workspaceCreateRequestSchema.parse({})).toThrow()
|
||||
@@ -396,45 +406,6 @@ describe('workspace domain schemas', () => {
|
||||
expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds)
|
||||
.toEqual(['w2', 'w1'])
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
|
||||
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands domain schemas', () => {
|
||||
it('validates the catalog request/value pair', () => {
|
||||
expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
// The wire is session-addressed only: a sessionId-less payload fails.
|
||||
expect(() => commandListRequestSchema.parse({})).toThrow()
|
||||
expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([])
|
||||
const value = commandListValueSchema.parse({ commands: [
|
||||
{ name: 'plan', description: 'Toggle plan mode' },
|
||||
{ name: 'goal', description: 'Set the goal', input: { hint: '<goal>' } },
|
||||
] })
|
||||
expect(value.commands[1]?.input?.hint).toBe('<goal>')
|
||||
expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined()
|
||||
expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow()
|
||||
expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow()
|
||||
})
|
||||
|
||||
it('validates the execute request/value pair with both matched branches', () => {
|
||||
expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off')
|
||||
// Both members are mandatory: dropping either fails the parse.
|
||||
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
|
||||
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
|
||||
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
|
||||
// Pure admission: matched plus the optional lifecycle pairing id
|
||||
// (outcomes ride the logged lifecycle events, never this response).
|
||||
expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' }))
|
||||
.toEqual({ matched: true, commandId: 'cmd-1' })
|
||||
expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
|
||||
expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow()
|
||||
expect(() => commandExecuteValueSchema.parse({})).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills domain schemas', () => {
|
||||
|
||||
@@ -32,21 +32,25 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Adaptive chooser of the directory-picker seam: resolves the host's
|
||||
* situation once at boot (bind host, SSH launch, display session, Linux
|
||||
* chooser binary) and mounts the matching dual-face backend — `-native` or
|
||||
* `-browse` — as a real Loader entry in the in-memory root tree. Because the
|
||||
* backend arrives as an ordinary entry, its browser half is discovered
|
||||
* exactly as a config-row's would be, so the seam's one-row-swaps-both-faces
|
||||
* invariant holds for the resolved choice; pinning an interaction remains
|
||||
* composing that backend row directly instead of this one.
|
||||
* chooser binary) and mounts the matching interaction — `native` or `browse`
|
||||
* — as real Loader entries in the in-memory root tree. Each interaction is a
|
||||
* pair: the Host backend serving the seam capability and the client surface
|
||||
* occupying ui-workspace's directory-flow holes. Both arrive as ordinary
|
||||
* entries, so the surface is discovered exactly as a config-row's would be
|
||||
* and one resolved choice still swaps both faces; pinning an interaction
|
||||
* remains composing that pair directly instead of this row.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto
|
||||
*/
|
||||
|
||||
@@ -28,7 +29,7 @@ export const name = 'directory-picker-auto'
|
||||
export const inject = ['httpServer', 'loader']
|
||||
|
||||
/**
|
||||
* Backend package per resolved kind — fixed composition vocabulary, not a
|
||||
* Host backend package per resolved kind — fixed composition vocabulary, not a
|
||||
* tunable. Exported because the reference is a runtime string the static
|
||||
* config gate cannot see in a yml row: `verify-cordis-config` requires every
|
||||
* app composing this chooser to declare both values as dependencies.
|
||||
@@ -39,10 +40,23 @@ export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the backend from one boot-time sample and mount it as a Loader
|
||||
* entry; the effect's disposer removes the entry and joins the backend
|
||||
* fiber's teardown, so unloading this plugin returns only after both faces
|
||||
* of the mounted backend (and their dependents) quiesced.
|
||||
* Client surface package per resolved kind, mounted with its backend so one
|
||||
* resolved interaction still composes both faces. Declared as dependencies by
|
||||
* every composing app for the same reason as {@link BACKEND_PACKAGES}. Only the
|
||||
* specifier is referenced here — the packages belong to the Client program, so
|
||||
* no import of them exists on this side and knip needs them ignored for this
|
||||
* workspace.
|
||||
*/
|
||||
export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
|
||||
native: '@deepseek-ai/dsh-client-ui-directory-picker-native',
|
||||
browse: '@deepseek-ai/dsh-client-ui-directory-picker',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the interaction from one boot-time sample and mount its backend and
|
||||
* surface as Loader entries; the effect's disposer removes both entries and
|
||||
* joins their fibers' teardown, so unloading this plugin returns only after
|
||||
* both faces of the mounted interaction (and their dependents) quiesced.
|
||||
* @param ctx - cordis context carrying the injected `httpServer` and `loader`.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<void> {
|
||||
@@ -54,16 +68,31 @@ export async function apply(ctx: Context): Promise<void> {
|
||||
})
|
||||
await ctx.effect(async () => {
|
||||
// Root-tree create: the Loader root is in-memory (write() is a no-op), so
|
||||
// the mounted row can never be persisted back into a config file.
|
||||
const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] })
|
||||
return async () => {
|
||||
// Tree teardown (group.stop) can have removed the entry already;
|
||||
// nothing is left to unmount or await then.
|
||||
const entry = ctx.loader.store[id]
|
||||
if (entry === undefined) return
|
||||
// remove() disposes the entry transactionally, so the chooser's unload
|
||||
// signals completion only after the backend quiesced.
|
||||
await ctx.loader.remove(id)
|
||||
// the mounted rows can never be persisted back into a config file. The
|
||||
// backend lands first: the surface's browser half drives the capability
|
||||
// the backend registers.
|
||||
const ids: string[] = []
|
||||
const unmount = async () => {
|
||||
for (const id of [...ids].reverse()) {
|
||||
// Tree teardown (group.stop) can have removed the entry already;
|
||||
// nothing is left to unmount or await then.
|
||||
if (ctx.loader.store[id] === undefined) continue
|
||||
// remove() disposes the entry transactionally, so the chooser's unload
|
||||
// signals completion only after that face quiesced.
|
||||
await ctx.loader.remove(id)
|
||||
}
|
||||
}
|
||||
}, 'directory-picker-auto: backend entry')
|
||||
try {
|
||||
for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) {
|
||||
ids.push(await ctx.loader.create({ name }))
|
||||
}
|
||||
} catch (cause) {
|
||||
// Setup owns the entries it created until it returns the disposer: leaving
|
||||
// the backend mounted would make a retry collide with its own
|
||||
// directoryPicker registration.
|
||||
await unmount()
|
||||
throw cause
|
||||
}
|
||||
return unmount
|
||||
}, 'directory-picker-auto: interaction entries')
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* REAL-composition coverage: a test-only cordis.yml booted through the
|
||||
* vendored Loader mounts the webserver row plus the adaptive chooser, and the
|
||||
* assertions observe the durable outcome — which backend entry the chooser
|
||||
* mounted into the Loader store, the capability the seam then serves, and
|
||||
* that disposing the chooser removes the mounted entry again (HMR safety),
|
||||
* joining the backend's own teardown before the disposer settles.
|
||||
* assertions observe the durable outcome — which backend and surface entries
|
||||
* the chooser mounted into the Loader store, the capability the seam then
|
||||
* serves, and that disposing the chooser removes both mounted entries again
|
||||
* (HMR safety), joining the backend's own teardown before the disposer settles.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
@@ -48,6 +48,23 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
|
||||
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native'
|
||||
const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker'
|
||||
|
||||
/**
|
||||
* Loader-visible stand-in for a client surface package: the surfaces belong to
|
||||
* the Client program and publish browser entry points only, so a Host-face spec
|
||||
* can neither name them in a static import nor resolve them from source. What
|
||||
* the chooser owns is the mounting decision, which every case observes through
|
||||
* the Loader store; the surface's own browser contributions belong to the
|
||||
* assembled web coverage.
|
||||
*
|
||||
* @param name Surface package specifier the chooser mounts.
|
||||
* @returns A function-plugin module the Loader can mount under that specifier.
|
||||
*/
|
||||
function surfaceModule(name: string): unknown {
|
||||
return { name, apply: () => undefined }
|
||||
}
|
||||
|
||||
let root: string | undefined
|
||||
let fakeBin: string | undefined
|
||||
@@ -71,7 +88,10 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
|
||||
async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
|
||||
async function loadComposition(
|
||||
bindHost: '127.0.0.1' | '0.0.0.0',
|
||||
options: { failSurface?: boolean } = {},
|
||||
): Promise<{ ctx: Context; configPath: string }> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
@@ -92,10 +112,15 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx
|
||||
[AUTO, DirectoryPickerAuto],
|
||||
[NATIVE, NativeDirectoryPicker],
|
||||
[BROWSE, BrowseDirectoryPicker],
|
||||
[NATIVE_SURFACE, surfaceModule(NATIVE_SURFACE)],
|
||||
[BROWSE_SURFACE, surfaceModule(BROWSE_SURFACE)],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (options.failSurface === true && (specifier === NATIVE_SURFACE || specifier === BROWSE_SURFACE)) {
|
||||
throw new Error(`surface import failed: ${specifier}`)
|
||||
}
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
@@ -142,7 +167,9 @@ describe('real Loader composition', () => {
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
expect(entryNames(ctx)).toContain(NATIVE)
|
||||
expect(entryNames(ctx)).toContain(NATIVE_SURFACE)
|
||||
expect(entryNames(ctx)).not.toContain(BROWSE)
|
||||
expect(entryNames(ctx)).not.toContain(BROWSE_SURFACE)
|
||||
const picker = ctx.get('directoryPicker') as DirectoryPicker
|
||||
expect(picker.capability().kind).toBe('native')
|
||||
// The mounted row lives in the Loader's in-memory root tree only — the
|
||||
@@ -155,6 +182,7 @@ describe('real Loader composition', () => {
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
await autoEntry.fiber!.dispose()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
expect(ctx.get('directoryPicker')).toBeUndefined()
|
||||
// Self-disposing an include-tree entry persists `disabled: true` (loader
|
||||
// behavior, not the chooser's); await that debounced write so it cannot
|
||||
@@ -170,7 +198,9 @@ describe('real Loader composition', () => {
|
||||
const { ctx } = await loadComposition('127.0.0.1')
|
||||
|
||||
expect(entryNames(ctx)).toContain(BROWSE)
|
||||
expect(entryNames(ctx)).toContain(BROWSE_SURFACE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
const picker = ctx.get('directoryPicker') as DirectoryPicker
|
||||
expect(picker.capability().kind).toBe('browse')
|
||||
})
|
||||
@@ -180,7 +210,20 @@ describe('real Loader composition', () => {
|
||||
const { ctx } = await loadComposition('0.0.0.0')
|
||||
|
||||
expect(entryNames(ctx)).toContain(BROWSE)
|
||||
expect(entryNames(ctx)).toContain(BROWSE_SURFACE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
})
|
||||
|
||||
it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/)
|
||||
|
||||
// Setup owns both entries until it returns its disposer, so a failed surface
|
||||
// must take the mounted backend with it: otherwise a retry collides with the
|
||||
// directoryPicker registration this backend already made.
|
||||
expect(entryNames(context!)).not.toContain(NATIVE)
|
||||
expect(context!.get('directoryPicker')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
|
||||
@@ -193,6 +236,7 @@ describe('real Loader composition', () => {
|
||||
renameControl.remainingFailures = 1
|
||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
// Same self-dispose persistence as above: let the write land before teardown.
|
||||
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
||||
expect(renameControl.injectedFailures).toBe(1)
|
||||
|
||||
@@ -22,55 +22,25 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
|
||||
* headless here — mask, card, Escape only — and this module owns the figma
|
||||
* frame: 680×500 card (viewport-clamped; upsized from the figma 600×420),
|
||||
* header (title + crumbs, l3 separator),
|
||||
* the one-or-two-column Miller content, and the bordered footer. */
|
||||
|
||||
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
|
||||
/* Short viewports clamp the card: header/footer are flex-none and the
|
||||
* columns scroll, so shrinking the height keeps Open/Cancel reachable
|
||||
* instead of clipping them below a fixed overlay. */
|
||||
.dialog.dialog {
|
||||
width: min(680px, 100%);
|
||||
height: min(500px, calc(100dvh - 32px));
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
/* The Modal card is an l2 surface and the columns below scroll on it:
|
||||
* rebind the scrollbar indirection to the elevation pair here, on the
|
||||
* surface, so it inherits down to whichever descendant scrolls (the
|
||||
* rebinding contract in ui-theme styles/scrollbar.css). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
|
||||
* observers; display:contents keeps header/content/footer as direct flex
|
||||
* children of the Modal card. */
|
||||
.editorScope {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 16px 14px 8px 24px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The bar IS the editor's box in both modes: it carries the rounded outline
|
||||
* and the inner padding, the crumbs and the input sit inside it, and hovering
|
||||
* the edit zone lights the whole row rather than the remainder right of the
|
||||
* crumbs. The negative left margin pays back the border and padding, so the
|
||||
* crumb (and input) text keeps the column the title sits in. */
|
||||
.crumbBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
min-height: 24px;
|
||||
margin-left: -9px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover
|
||||
* offers navigation, not path entry. Editing keeps the outline standing. */
|
||||
.crumbBar:has(.crumbEditZone:enabled:hover),
|
||||
.crumbBar:has(.crumbEditZone:focus-visible),
|
||||
.crumbBar:has(.pathInput) {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Deep chains scroll inside the trail (the effect pins the tail into view)
|
||||
* so the edit zone to the right never leaves the bar. */
|
||||
/* The Miller columns keep their own row so a status/error line below never
|
||||
* competes with the fixed column widths for horizontal space. */
|
||||
/* A narrow viewport shrinks the dialog below two fixed panes; the row
|
||||
* scrolls horizontally (the effect pins the child pane into view) so
|
||||
* descent never hides behind the Modal's clipping. */
|
||||
.millerRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
/* 12px of row gap on each side of the divider; the left side reads wider
|
||||
* by the column's trailing 8px scrollbar clearance, which is deliberate —
|
||||
* the thumb needs that room, the right pane's rows do not. */
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.crumbTrail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.crumbSeat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.crumbChevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The empty remainder of the bar: a real click target that flips the bar into
|
||||
* path-edit mode. The pencil glyph seated at its right edge is the standing
|
||||
* affordance; the outline the gesture lights belongs to the bar, so the whole
|
||||
* row reads as the box the input will occupy. */
|
||||
.crumbEditZone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1 0 34px;
|
||||
min-width: 34px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.crumbEditGlyph {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.crumbEditZone:enabled:hover .crumbEditGlyph,
|
||||
.crumbEditZone:focus-visible .crumbEditGlyph {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.crumbEditZone:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.crumbEditZone:disabled .crumbEditGlyph {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Chrome-free: the bar around it draws the box (border, radius, padding). */
|
||||
.pathInput {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Miller content: symmetric 16px vertical padding so the divider clears the
|
||||
* header and footer rules evenly; each column scrolls alone (column widths
|
||||
* live at .column). */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
/* Anchors the floating loading pill (.loadingFloat). */
|
||||
position: relative;
|
||||
/* Right inset is slimmer than the left: the trailing column's own 8px
|
||||
* scrollbar clearance makes up the optical difference. */
|
||||
padding: 16px 16px 16px 24px;
|
||||
}
|
||||
|
||||
/* Columns split the row evenly around the divider (a solo column takes the
|
||||
* whole row); 256px is the floor below which the row scrolls (scrollbar
|
||||
* hidden, the effect pins the child pane into view) instead of squeezing
|
||||
* the panes. */
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 0;
|
||||
min-width: 256px;
|
||||
overflow-y: auto;
|
||||
/* The themed scrollbar occupies the column's edge (styled scrollbars are
|
||||
* classic, gutter-taking ones); the extra clearance keeps the row pills
|
||||
* clear of the thumb. */
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
background: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.rowSeat {
|
||||
display: flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Selection: pill fill + the open-folder glyph in the info accent. */
|
||||
.rowSelected,
|
||||
.rowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
|
||||
}
|
||||
|
||||
.rowIcon {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.rowIconSelected {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-button-info-fill);
|
||||
}
|
||||
|
||||
.rowName {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.rowChevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
padding: 4px;
|
||||
/* The loading pill occupies the opposite corner while a stale status stays
|
||||
* visible. Reserve its widest localized footprint so wrapped text cannot
|
||||
* run underneath it on a narrow card. */
|
||||
padding-right: 120px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The slow-scan indicator floats over the content's bottom-RIGHT corner on
|
||||
* the card background instead of occupying a row: a scan must never shift
|
||||
* the columns' height, and the stale view keeps rendering beneath it (it
|
||||
* only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right,
|
||||
* not left: the truncated/error status rows flow at the bottom LEFT and
|
||||
* stay on screen through a scan, with their reserved right padding keeping
|
||||
* both legible even on a narrow card. After .status in the cascade — the
|
||||
* element carries both classes and this padding must win the
|
||||
* same-specificity race. */
|
||||
.loadingFloat {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
|
||||
* centered in the bar; New-folder and the show-hidden toggle pin left. */
|
||||
.footerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Narrow viewports wrap the confirm/cancel pair onto their own row
|
||||
* instead of clipping Open past the card's hidden overflow. */
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* Show-hidden toggle: a subtle fixed-label text button left of the gap;
|
||||
* the pressed state seats a check glyph after the label (Menu's selected
|
||||
* vocabulary; trailing so the label never shifts) instead of flipping the
|
||||
* wording. */
|
||||
.showHiddenToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.showHiddenToggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.showHiddenToggle:disabled {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.showHiddenToggleActive {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.footerGap {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.footerAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
/* Nested create dialog (figma 813:23278): a small centered card. */
|
||||
.createDialog.createDialog {
|
||||
width: min(380px, 100%);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.createBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 22px 24px 20px;
|
||||
}
|
||||
|
||||
.createTitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.createIn {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.createInput {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.createInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.createActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* The browse picking occupant (package-internal; the `./client` surface
|
||||
* exposes only the Loader exports). Same-package tests exercise it directly
|
||||
* through this module.
|
||||
*/
|
||||
import { createElement } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the owner contract of the directory-flow holes.
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { DirectoryBrowser } from './DirectoryBrowser.tsx'
|
||||
|
||||
/** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */
|
||||
export interface BrowseFlowInjected {
|
||||
/** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */
|
||||
listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
|
||||
/** Create one child directory under an existing parent. */
|
||||
createDirectory: (path: string, name: string) => Promise<string>
|
||||
/** Localized dialog copy (this package's namespace). */
|
||||
t: Translate
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow occupant: adapts the hole's owner conversation onto the browser
|
||||
* dialog — a confirmed directory is the picked path, dismissal is the
|
||||
* cancellation. Browse failures (unreadable targets, create conflicts) stay
|
||||
* inside the dialog's own alert surfaces, so the owner's `onError` arm is
|
||||
* never driven by this occupant.
|
||||
* @param props - owner conversation plus the injected browse face.
|
||||
* @returns the dialog element (renders nothing while closed).
|
||||
*/
|
||||
export function BrowseDirectoryFlow(props: DirectoryFlowOwnerProps & BrowseFlowInjected): ReactElement {
|
||||
return createElement(DirectoryBrowser, {
|
||||
open: props.open,
|
||||
busy: props.busy,
|
||||
listDirectory: props.listDirectory,
|
||||
createDirectory: props.createDirectory,
|
||||
t: props.t,
|
||||
onOpen: props.onPicked,
|
||||
onClose: props.onCancel,
|
||||
})
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Browser half of the browse directory-picker backend: fills ui-workspace's
|
||||
* two directory-flow holes with the in-app Select Workspace Directory dialog
|
||||
* (figma `Harness` 813-23126 family), driving the node half's
|
||||
* `host.listDirectory`/`host.createDirectory` primitives. Mounting this
|
||||
* package therefore composes both sides of the browse interaction with one
|
||||
* cordis.yml row; no client code branches on a capability kind. The dialog's
|
||||
* copy is locale-registered here — the flow package owns its own strings.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { BrowseFlowInjected } from './flow.ts'
|
||||
import { BrowseDirectoryFlow } from './flow.ts'
|
||||
|
||||
/** Locale namespace owning the browser dialog's copy. */
|
||||
const LOCALE_NS = 'directory-browser'
|
||||
|
||||
/** Required services (cordis fiber inject): the slot registry, the wire-facing workspace service, and locale. */
|
||||
export const inject = ['slots', 'workspaces', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dialog's dictionaries and the browse flow
|
||||
* into both directory-flow holes through `slots.inject()` because the
|
||||
* ui-workspace entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
// The two dictionaries land as a unit: if the second registration hits a
|
||||
// rival owner of the namespace, the first rolls back before the throw —
|
||||
// a failed activation must not squat the namespace's other locale.
|
||||
const disposers: (() => void)[] = []
|
||||
const dictionaries: [locale: string, dict: Record<string, string>][] = [
|
||||
['zh', {
|
||||
'browser.title': '选择工作区目录',
|
||||
'browser.home': '主目录',
|
||||
'browser.newFolder': '新建文件夹',
|
||||
'browser.folderName': '文件夹名称',
|
||||
'browser.createIn': '在"{name}"中新建文件夹',
|
||||
'browser.untitledFolder': '未命名文件夹',
|
||||
'browser.create': '创建',
|
||||
'browser.cancel': '取消',
|
||||
'browser.open': '打开',
|
||||
'browser.editPath': '编辑路径',
|
||||
'browser.loading': '加载中…',
|
||||
'browser.truncated': '文件夹过多,仅显示开头部分。',
|
||||
'browser.showHidden': '显示隐藏文件',
|
||||
}],
|
||||
['en', {
|
||||
'browser.title': 'Select Workspace Directory',
|
||||
'browser.home': 'Home',
|
||||
'browser.newFolder': 'New folder',
|
||||
'browser.folderName': 'Folder name',
|
||||
'browser.createIn': 'New folder in "{name}"',
|
||||
'browser.untitledFolder': 'Untitled folder',
|
||||
'browser.create': 'Create',
|
||||
'browser.cancel': 'Cancel',
|
||||
'browser.open': 'Open',
|
||||
'browser.editPath': 'Edit path',
|
||||
'browser.loading': 'Loading…',
|
||||
'browser.truncated': 'Too many folders to list; only the beginning is shown.',
|
||||
'browser.showHidden': 'Show hidden files',
|
||||
}],
|
||||
]
|
||||
try {
|
||||
for (const [locale, dict] of dictionaries) disposers.push(ctx.locale.register(LOCALE_NS, locale, dict))
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
throw error
|
||||
}
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'directory-picker-browse: dialog dictionaries')
|
||||
|
||||
const injected = (): BrowseFlowInjected => ({
|
||||
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
|
||||
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
|
||||
t: ctx.locale.bind(LOCALE_NS),
|
||||
})
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, BrowseDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, BrowseDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -1,223 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { BrowseDirectoryFlow } from '../src/client/flow.ts'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
|
||||
|
||||
const HOME = '/home/u'
|
||||
const homeListing: DirectoryListing = {
|
||||
path: HOME,
|
||||
home: HOME,
|
||||
crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'u', path: HOME, hidden: false }],
|
||||
entries: [{ name: 'Documents', path: `${HOME}/Documents`, hidden: false }],
|
||||
truncated: false,
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
|
||||
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
|
||||
ctx.provide('workspaces', { listDirectory, createDirectory } as never)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
const declare = () => slots.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, () => null)
|
||||
return { ctx, slots, listDirectory, createDirectory, declare }
|
||||
}
|
||||
|
||||
function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
|
||||
return {
|
||||
open: true, busy: false,
|
||||
onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('directory-picker-browse client half', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'workspaces', 'locale'])
|
||||
})
|
||||
|
||||
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
|
||||
const before = await bench()
|
||||
before.declare()
|
||||
const fiber = before.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
|
||||
// Registry-contribution disposal proof: the fiber going down empties the holes.
|
||||
await fiber.dispose()
|
||||
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
|
||||
after.declare()
|
||||
await Promise.resolve()
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(fiber.await()).rejects.toThrow(/already has a registration/)
|
||||
// A leaked first deferral would now race this probe registration and
|
||||
// throw from its orphaned subscription against the HERO hole; the
|
||||
// rollback leaves only the activation failure itself (cordis re-raises
|
||||
// the apply throw as a late rejection — installFailLoud's contract).
|
||||
const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
|
||||
disposeProbe()
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
||||
expect(rejections.map(String).join('\n')).toContain('already has a registration')
|
||||
|
||||
// Non-Error conflicts wrap before the loud rethrow (same channel).
|
||||
const c = await bench()
|
||||
await c.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const original = c.slots.register.bind(c.slots)
|
||||
const slotsAny = c.slots as { register: typeof original }
|
||||
slotsAny.register = ((options: never, component: never) => {
|
||||
if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
|
||||
return original(options, component)
|
||||
}) as typeof original
|
||||
c.declare()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(rejections.map(String).join('\n')).toContain('string conflict')
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
process.off('uncaughtException', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the zh dictionary when a rival already owns the namespace en slot', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
const locale = b.ctx.get('locale') as LocaleService
|
||||
const disposeRival = locale.register('directory-browser', 'en', { 'browser.title': 'rival' })
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
// cordis re-raises the apply throw as a late rejection (installFailLoud's contract).
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(fiber.await()).rejects.toThrow(/already has locale/)
|
||||
// The zh registration rolled back with the failure: once the rival
|
||||
// leaves, a fresh registrant owns the whole namespace again.
|
||||
disposeRival()
|
||||
const disposeZh = locale.register('directory-browser', 'zh', { 'browser.title': '空闲' })
|
||||
disposeZh()
|
||||
} finally {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers the dialog dictionaries and binds this package namespace', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries(HOLES[0])[0]!
|
||||
const injected = (entry.inject as () => { t: (key: string) => string })()
|
||||
// zh is the shipped default locale.
|
||||
expect(injected.t('browser.title')).toBe('选择工作区目录')
|
||||
expect(injected.t('browser.newFolder')).toBe('新建文件夹')
|
||||
expect(injected.t('browser.showHidden')).toBe('显示隐藏文件')
|
||||
})
|
||||
|
||||
it('drives the injected browse calls through the hole entry', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries(HOLES[1])[0]!
|
||||
const injected = (entry.inject as () => {
|
||||
listDirectory: (path?: string) => Promise<DirectoryListing>
|
||||
createDirectory: (path: string, name: string) => Promise<string>
|
||||
})()
|
||||
await expect(injected.listDirectory()).resolves.toBe(homeListing)
|
||||
await expect(injected.createDirectory(HOME, 'fresh')).resolves.toBe(`${HOME}/fresh`)
|
||||
expect(b.listDirectory).toHaveBeenCalledOnce()
|
||||
expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh')
|
||||
})
|
||||
|
||||
it('adapts the owner conversation onto the dialog: confirm picks, dismissal cancels', async () => {
|
||||
const props = owner()
|
||||
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
|
||||
const t = (key: string): string => key
|
||||
render(
|
||||
<BrowseDirectoryFlow
|
||||
{...props}
|
||||
listDirectory={listDirectory}
|
||||
createDirectory={vi.fn(async () => '')}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
// The dialog opened at home; its confirm (browser.open) adopts the listed level.
|
||||
const openButton = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
|
||||
await waitFor(() => { expect(openButton.disabled).toBe(false) })
|
||||
fireEvent.click(openButton)
|
||||
expect(props.onPicked).toHaveBeenCalledWith(HOME)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
|
||||
expect(props.onCancel).toHaveBeenCalled()
|
||||
expect(props.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders nothing while the flow is closed', () => {
|
||||
const view = render(
|
||||
<BrowseDirectoryFlow
|
||||
{...owner({ open: false })}
|
||||
listDirectory={vi.fn(async () => homeListing)}
|
||||
createDirectory={vi.fn(async () => '')}
|
||||
t={key => key}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
@@ -16,21 +16,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../../client/locale"
|
||||
},
|
||||
{
|
||||
"path": "../../client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-host-directory-picker-browse', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
/** Node-only backend: listing and creation primitives over the host filesystem. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -22,10 +22,6 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./worker": {
|
||||
"types": "./lib/types/win32-dialog-worker.d.ts",
|
||||
"default": "./lib/worker.cjs"
|
||||
@@ -37,7 +33,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/worker.cjs",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -47,30 +42,12 @@
|
||||
"koffi": "^3.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"tsx": "^4.19.2"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* The native picking occupant (package-internal; the `./client` surface
|
||||
* exposes only the Loader exports). Same-package tests exercise it directly
|
||||
* through this module.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
// Type-only: the owner contract of the directory-flow holes.
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
|
||||
/** Injected face: the wire call the flow drives (bound in apply's closure). */
|
||||
export interface NativeFlowInjected {
|
||||
/** Ask the local Host to open its native single-directory chooser. */
|
||||
pick: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderless flow occupant: each rising `open` edge runs exactly one pick and
|
||||
* reports exactly one outcome; the ref arms once per open so re-renders (and
|
||||
* an adoption keeping `open` true while `busy`) never launch a second
|
||||
* chooser. The owner withdrawing `open` re-arms the next request.
|
||||
* @param props - owner conversation plus the injected pick call.
|
||||
* @returns nothing — the native chooser renders on the host display.
|
||||
*/
|
||||
export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null {
|
||||
const { open, pick } = props
|
||||
const armed = useRef(false)
|
||||
// Callbacks ride a ref so the settled pick reports through the owner's
|
||||
// latest handlers, not the ones captured when the chooser opened.
|
||||
const outcome = useRef(props)
|
||||
outcome.current = props
|
||||
// Unmount (HMR replacing the occupant) discards settlements wholesale: the
|
||||
// dead instance must neither adopt a path nor drive the owner's error
|
||||
// surface. The wire carries no per-request abort, so the host-side chooser
|
||||
// survives until answered — its answer just lands nowhere; the replacement
|
||||
// instance re-arms under the owner's still-open request. An injected-face
|
||||
// identity change alone (re-registration) keeps the pending settlement:
|
||||
// the chooser on the host display is still the same dialog.
|
||||
const alive = useRef(true)
|
||||
useEffect(() => {
|
||||
// StrictMode's development replay runs the cleanup once before the real
|
||||
// lifetime: re-arm on setup or every outcome would be discarded.
|
||||
alive.current = true
|
||||
return () => { alive.current = false }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
armed.current = false
|
||||
return
|
||||
}
|
||||
if (armed.current) return
|
||||
armed.current = true
|
||||
pick().then(
|
||||
(path) => {
|
||||
if (!alive.current) return
|
||||
if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path)
|
||||
},
|
||||
(reason: unknown) => {
|
||||
if (!alive.current) return
|
||||
outcome.current.onError(reason instanceof Error ? reason.message : String(reason))
|
||||
},
|
||||
)
|
||||
}, [open, pick])
|
||||
return null
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Browser half of the native directory-picker backend: fills ui-workspace's
|
||||
* two directory-flow holes with a renderless occupant that answers each
|
||||
* `open` by driving `host.pickDirectory` (the node half's OS chooser) and
|
||||
* reporting the one outcome — picked path, cancellation, or failure — back
|
||||
* through the owner conversation. Mounting this package therefore composes
|
||||
* both sides of the native interaction with one cordis.yml row; no client
|
||||
* code branches on a capability kind.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { NativeFlowInjected } from './flow.ts'
|
||||
import { NativeDirectoryFlow } from './flow.ts'
|
||||
|
||||
|
||||
/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */
|
||||
export const inject = ['slots', 'workspaces']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the renderless native flow into both
|
||||
* directory-flow holes through `slots.inject()` because the ui-workspace
|
||||
* entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { NativeDirectoryFlow } from '../src/client/flow.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
|
||||
ctx.provide('workspaces', { pickDirectory } as never)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
const declare = () => slots.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, () => null)
|
||||
return { ctx, slots, pickDirectory, declare }
|
||||
}
|
||||
|
||||
function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
|
||||
return {
|
||||
open: true, busy: false,
|
||||
onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('directory-picker-native client half', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'workspaces'])
|
||||
})
|
||||
|
||||
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
|
||||
const before = await bench()
|
||||
before.declare()
|
||||
const fiber = before.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
|
||||
// Registry-contribution disposal proof: the fiber going down empties the holes.
|
||||
await fiber.dispose()
|
||||
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
|
||||
after.declare()
|
||||
await Promise.resolve()
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails loudly instead of deduplicating a duplicate package row', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const duplicate = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(duplicate.await()).rejects.toThrow(/already has a registration/)
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
// queueMicrotask throws surface as uncaughtException, not a rejection.
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
||||
expect(rejections.map(String).join('\n')).toContain('already has a registration')
|
||||
|
||||
// Non-Error conflicts wrap before the loud rethrow (same channel).
|
||||
const c = await bench()
|
||||
await c.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const original = c.slots.register.bind(c.slots)
|
||||
const slotsAny = c.slots as { register: typeof original }
|
||||
slotsAny.register = ((options: never, component: never) => {
|
||||
if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
|
||||
return original(options, component)
|
||||
}) as typeof original
|
||||
c.declare()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(rejections.map(String).join('\n')).toContain('string conflict')
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
process.off('uncaughtException', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(fiber.await()).rejects.toThrow(/already has a registration/)
|
||||
// A leaked first deferral would now race this probe registration and
|
||||
// throw from its orphaned subscription against the HERO hole; the
|
||||
// rollback leaves only the activation failure itself (cordis re-raises
|
||||
// the apply throw as a late rejection — installFailLoud's contract).
|
||||
const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
|
||||
disposeProbe()
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a second flow occupant at load (single-kind hole)', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(() => b.slots.register({ name: HOLES[0] } as never, () => null))
|
||||
.toThrow(/already has a registration/)
|
||||
})
|
||||
|
||||
it('drives the injected pick through the hole entry and reports the picked path', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries(HOLES[0])[0]!
|
||||
const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
|
||||
await expect(injected.pick()).resolves.toBe('/tmp/picked')
|
||||
expect(b.pickDirectory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
|
||||
let resolve!: (path: string | null) => void
|
||||
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
|
||||
const first = owner()
|
||||
const view = render(<NativeDirectoryFlow {...first} pick={pick} />)
|
||||
expect(pick).toHaveBeenCalledOnce()
|
||||
// Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser.
|
||||
const second = owner()
|
||||
view.rerender(<NativeDirectoryFlow {...second} busy pick={pick} />)
|
||||
expect(pick).toHaveBeenCalledOnce()
|
||||
// Even a fresh injected face (re-registration re-runs the inject factory)
|
||||
// must not relaunch while the same request is still open.
|
||||
const replacedPick = vi.fn(() => new Promise<string | null>(() => {}))
|
||||
view.rerender(<NativeDirectoryFlow {...second} busy pick={replacedPick} />)
|
||||
expect(replacedPick).not.toHaveBeenCalled()
|
||||
await act(async () => { resolve('/tmp/project') })
|
||||
expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
|
||||
expect(first.onPicked).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a settlement that lands after the flow unmounted', async () => {
|
||||
let resolve!: (path: string | null) => void
|
||||
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
|
||||
const props = owner()
|
||||
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
|
||||
expect(pick).toHaveBeenCalledOnce()
|
||||
view.unmount()
|
||||
// The dead instance must neither adopt nor error; the owner's callbacks
|
||||
// stay untouched by the orphaned chooser's answer.
|
||||
await act(async () => { resolve('/tmp/late') })
|
||||
expect(props.onPicked).not.toHaveBeenCalled()
|
||||
expect(props.onCancel).not.toHaveBeenCalled()
|
||||
expect(props.onError).not.toHaveBeenCalled()
|
||||
|
||||
// The failure arm is discarded the same way.
|
||||
let reject!: (reason: unknown) => void
|
||||
const failing = vi.fn(() => new Promise<string | null>((_settle, rejectPick) => { reject = rejectPick }))
|
||||
const late = owner()
|
||||
const failingView = render(<NativeDirectoryFlow {...late} pick={failing} />)
|
||||
failingView.unmount()
|
||||
await act(async () => { reject(new Error('too late')) })
|
||||
expect(late.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports null as cancellation and re-arms after the owner withdraws open', async () => {
|
||||
const pick = vi.fn(async () => null as string | null)
|
||||
const props = owner()
|
||||
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
|
||||
await act(async () => {})
|
||||
expect(props.onCancel).toHaveBeenCalledOnce()
|
||||
expect(props.onPicked).not.toHaveBeenCalled()
|
||||
// Withdraw and reopen: a fresh request runs a fresh pick.
|
||||
view.rerender(<NativeDirectoryFlow {...props} open={false} pick={pick} />)
|
||||
view.rerender(<NativeDirectoryFlow {...props} pick={pick} />)
|
||||
await act(async () => {})
|
||||
expect(pick).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('folds pick failures into onError messages', async () => {
|
||||
const props = owner()
|
||||
render(<NativeDirectoryFlow {...props} pick={vi.fn(async () => { throw new Error('no chooser installed') })} />)
|
||||
await act(async () => {})
|
||||
expect(props.onError).toHaveBeenCalledWith('no chooser installed')
|
||||
|
||||
const nonError = owner()
|
||||
render(<NativeDirectoryFlow {...nonError} pick={vi.fn(async () => { throw 'denied' })} />)
|
||||
await act(async () => {})
|
||||
expect(nonError.onError).toHaveBeenCalledWith('denied')
|
||||
})
|
||||
|
||||
it('renders nothing while closed and while open', () => {
|
||||
const closed = render(<NativeDirectoryFlow {...owner({ open: false })} pick={vi.fn(async () => null)} />)
|
||||
expect(closed.container.innerHTML).toBe('')
|
||||
const opened = render(<NativeDirectoryFlow {...owner()} pick={vi.fn(async () => null)} />)
|
||||
expect(opened.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
@@ -19,15 +19,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/native-command"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,23 +1,31 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
// The Win32 dialog worker builds as its own CJS entry (mirroring
|
||||
// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining
|
||||
// the dialog logic while koffi stays an external native require.
|
||||
export default clientBundle(
|
||||
'@deepseek-ai/dsh-host-directory-picker-native',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
/**
|
||||
* Node-only backend. The Win32 dialog worker builds as its own CJS entry
|
||||
* (mirroring dsh-workflow-workerthread's worker): path-loaded by the driver,
|
||||
* inlining the dialog logic while koffi stays an external native require.
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
companions: [{
|
||||
// The artifact is lib/worker.cjs (the ./worker export the workspace
|
||||
// constraint keys on), bundled from the descriptive source entry.
|
||||
entry: { worker: 'lib/types/win32-dialog-worker.js' },
|
||||
outDir: 'lib',
|
||||
format: ['cjs'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
}],
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
)
|
||||
{
|
||||
// The artifact is lib/worker.cjs (the ./worker export the workspace
|
||||
// constraint keys on), bundled from the descriptive source entry.
|
||||
entry: { worker: 'lib/types/win32-dialog-worker.js' },
|
||||
outDir: 'lib',
|
||||
format: ['cjs'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user