refactor(commands): move the command service to Remote

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

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

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

View File

@@ -1,10 +1,12 @@
/** Platform-neutral assembly of generated Host Remote contributions. */ /** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-goal/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's // The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails. // compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
@@ -17,12 +19,22 @@ export type {} from '@deepseek-ai/dsh-credentials/types'
export type {} from '@deepseek-ai/dsh-llm/types' export type {} from '@deepseek-ai/dsh-llm/types'
export type {} from '@deepseek-ai/dsh-agent-presets/types' export type {} from '@deepseek-ai/dsh-agent-presets/types'
export type {} from '@deepseek-ai/dsh-settings/types' export type {} from '@deepseek-ai/dsh-settings/types'
/** /**
* The Gateway Client face's own declaration merges, type-only: `ctx.remote` and * The carrier's Client-facing types, re-exported so a business package names one
* with it the `$on`/`$dispatch` surface. Erased at emit, so this facade still * assembly package instead of both this facade and the Connection plugin. Type-only:
* carries no runtime edge to the Gateway implementation. * the carrier's runtime values stay behind their own module edge.
*/ */
export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {
ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient,
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem,
SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
SubagentAddress, SubagentCatalog, TaskView, ToolCallView, ToolEventView, ToolResultView,
WorkspaceId, WorkspaceView
} from '@deepseek-ai/dsh-client-connection/client'
declare module '@deepseek-ai/cordis' { declare module '@deepseek-ai/cordis' {
interface Context { interface Context {
@@ -40,5 +52,16 @@ export const inject = ['remote']
* @returns disposer after every selected Remote namespace is ready. * @returns disposer after every selected Remote namespace is ready.
*/ */
export async function apply(ctx: Context): Promise<() => Promise<void>> { export async function apply(ctx: Context): Promise<() => Promise<void>> {
return await ctx.remote.$mount(goalsRemote) const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [commandsRemote, goalsRemote]) {
disposers.push(await ctx.remote.$mount(contribution))
}
} catch (error) {
for (const dispose of disposers.reverse()) await dispose()
throw error
}
return async () => {
for (const dispose of disposers.reverse()) await dispose()
}
} }

View File

@@ -10,7 +10,7 @@ export type {
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing, DirectoryEntry, DirectoryListing,
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef, GoalsApi, GoalRef,

View File

@@ -28,6 +28,7 @@ import type {
// Type-only: the brand constructor is host-side; the fixture casts at its // Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture). // wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types'
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
import type { import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
@@ -1379,11 +1380,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return createFixtureWorld(options).api return createFixtureWorld(options).api
} }
interface FixtureWorld { /** Both fixture faces over one state graph. */
export interface FixtureWorld {
/** Legacy unary/stream API the fixture still answers. */
readonly api: ApiProxy readonly api: ApiProxy
/** Generic Remote caller for the endpoints business services own. */
readonly rpc: ClientConnectionRpc readonly rpc: ClientConnectionRpc
} }
/**
* Build both fixture faces so a caller can drive the Remote endpoints and the
* legacy API against one in-memory state graph.
* @param options - fixture branches for empty state and failure timing.
* @returns the legacy API face and the Remote RPC face.
*/
export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld {
return createFixtureWorld(options)
}
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */ /** Build the fixture's legacy API and Remote RPC faces over one state graph. */
function createFixtureWorld(options: FixtureOptions): FixtureWorld { function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// The resident fixture sessions all carry history, so none of them is blank. // The resident fixture sessions all carry history, so none of them is blank.
@@ -1598,6 +1612,98 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
: undefined : undefined
) )
/** Canonical fixture implementation of the generated Commands Remote contract. */
const commandRemotes = {
list(id: SessionId): RpcResult<readonly CommandDescriptor[]> {
const missing = requireGoalSession(id)
if (missing !== undefined) return missing
return {
ok: true,
value: [
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
],
}
},
execute(id: SessionId, line: string): RpcResult<CommandExecution | undefined> {
const missing = requireGoalSession(id)
if (missing !== undefined) return missing
// Structured split mirroring the Host parser: name + verbatim rawInput
// (separator whitespace included) — the run payload carries no line.
const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim())
const name = match?.[1]
const args = match?.[2] ?? ''
if (name === 'permission') {
const preset = args.trim()
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const spec = PERMISSION_PRESETS[preset]
let result: CommandResult
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
result = { kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` }
} else if (spec === undefined) {
result = { kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` }
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
result = { kind: 'success', text: `preset ${preset}` }
}
append(id, { type: 'command/done', data: { commandId, ...result } })
return { ok: true, value: { commandId, result } }
}
if (name === 'goal') {
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const objective = args.trim()
const current = backscanGoal(logOf(id))
let text: string
if (objective === '') {
text = current === null ? 'No goal is set. Usage: /goal <objective>' : `Current goal: ${current.goal.objective}`
} else if (current !== null && current.goal.phase !== 'complete') {
text = `A goal already exists (${current.goal.objective}). Clear it first.`
} else {
const created = appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'create',
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 },
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
})
text = `Goal created: ${created.goal.objective}`
}
const result: CommandResult = { kind: 'success', text }
append(id, { type: 'command/done', data: { commandId, ...result } })
return { ok: true, value: { commandId, result } }
}
const running = summaryOf(id)?.running === true
const outcomes: Record<string, string> = {
compact: 'fixture:已压缩(假动作)',
echo: args.trim(),
plan: args.trim() === 'off'
? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.')
: (running
? 'Entering plan mode (applies from the next step). Use /plan off to leave.'
: 'Plan mode on. Use /plan off to leave.'),
}
const text = name === undefined ? undefined : outcomes[name]
if (name === undefined || text === undefined) return { ok: true, value: undefined }
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
if (name === 'plan' && !running) {
const plan = foldPlan(logOf(id))
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
}
const result: CommandResult = { kind: 'success', ...text === '' ? {} : { text } }
append(id, { type: 'command/done', data: { commandId, ...result } })
return { ok: true, value: { commandId, result } }
},
}
const goalView = (projection: FxGoalProjection): FxGoalView => ({ const goalView = (projection: FxGoalProjection): FxGoalView => ({
...projection.goal, ...projection.goal,
roundsStarted: projection.roundsStarted, roundsStarted: projection.roundsStarted,
@@ -2446,104 +2552,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
return ok(request, { archivedSessionIds: [...archivedSessionIds] }) return ok(request, { archivedSessionIds: [...archivedSessionIds] })
}, },
}, },
commands: {
// The catalog mirrors one session's effective view (every fixture
// session has an agent, like the real host).
list: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
return ok(request, {
commands: [
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
],
})
},
// Pure admission, mirroring the host: an admitted command logs the
// command/run + command/done lifecycle pair (mux-broadcast by append),
// and the response only reports resolution.
execute: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const id = request.payload.sessionId
// Structured split mirroring the host parser: name + verbatim rawInput
// (separator whitespace included) — the run payload carries no line.
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
const name = match?.[1]
const args = match?.[2] ?? ''
// /permission mirrors the host handler: switch through the knob
// events (each append pushes a permissions projection frame).
if (name === 'permission') {
const preset = args.trim()
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const spec = PERMISSION_PRESETS[preset]
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else if (spec === undefined) {
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } })
}
return ok(request, { matched: true as const, commandId })
}
if (name === 'goal') {
// Host parallel: /goal with an objective creates (or reports) the
// current goal; the command lifecycle pair brackets the mutation.
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const objective = args.trim()
const current = backscanGoal(logOf(id))
let text: string
if (objective === '') {
text = current === null ? 'No goal is set. Usage: /goal <objective>' : `Current goal: ${current.goal.objective}`
} else if (current !== null && current.goal.phase !== 'complete') {
text = `A goal already exists (${current.goal.objective}). Clear it first.`
} else {
const created = appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'create',
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 },
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
})
text = `Goal created: ${created.goal.objective}`
}
append(id, { type: 'command/done', data: { commandId, kind: 'success', text } })
return ok(request, { matched: true as const, commandId })
}
// Host parallel: /plan on an idle fixture session commits plan/mode
// immediately (the boundary flush covers only a running turn), so the
// outcome copy matches the immediate branch of the host handler.
const running = summaryOf(id)?.running === true
const outcomes: Record<string, string> = {
compact: 'fixture:已压缩(假动作)',
echo: args.trim(),
plan: args.trim() === 'off'
? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.')
: (running
? 'Entering plan mode (applies from the next step). Use /plan off to leave.'
: 'Plan mode on. Use /plan off to leave.'),
}
const text = name === undefined ? undefined : outcomes[name]
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
if (name === 'plan' && !running) {
const plan = foldPlan(logOf(id))
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
}
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
return ok(request, { matched: true as const, commandId })
},
},
agentPresets: { agentPresets: {
// Both trusts appear, because a surface must present a locally authored // Both trusts appear, because a surface must present a locally authored
// preset differently from one the deployment vetted. // preset differently from one the deployment vetted.
@@ -2852,12 +2860,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const args = (payload as { const args = (payload as {
args: { args: {
agentId: SessionId agentId: SessionId
line?: string
ref?: { id: string; revision: number } ref?: { id: string; revision: number }
request?: { objective?: string; maxGoalRounds?: number } request?: { objective?: string; maxGoalRounds?: number }
} }
}).args }).args
const sessionId = args.agentId const sessionId = args.agentId
switch (endpoint) { switch (endpoint) {
case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId))
case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string))
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
objective: args.request?.objective as string, objective: args.request?.objective as string,
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
@@ -2950,8 +2961,6 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'workspace.archiveSession': return this.api.workspace.archiveSession(request)
case 'command.list': return this.api.commands.list(request)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request) case 'skill.list': return this.api.skills.list(request)
case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.list': return this.api.agentPresets.list(request)
case 'agentPreset.select': return this.api.agentPresets.select(request) case 'agentPreset.select': return this.api.agentPresets.select(request)

View File

@@ -18,7 +18,7 @@ export type {
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing, DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,

View File

@@ -1,9 +1,8 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and // data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { import type {
CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame, HostFrame, IApiClient, ModelSelection, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
} from '../src/client/api.ts' } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts'
@@ -169,19 +168,10 @@ export class FakeApiClient implements IApiClient {
// Payloads stay `unknown` (lint-lane note above); response rows are the real // Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program catalogs and skill lists without casts. // wire shapes so cases can program catalogs and skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] })) = () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly agentPresets: IApiClient['agentPresets'] = { readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
select: (payload: { agentPreset: string }) => select: (payload: { agentPreset: string }) =>

View File

@@ -1,28 +1,35 @@
/** /**
* Fixture commands/skills domains: contract-shape conformance for the two * Fixture commands/skills domains: session-addressed catalogs, execute
* domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute * parse/dispatch and its logged lifecycle pair, skill.list session resolution,
* parse/dispatch, skill.list session resolution, and the FixtureApiClient * and the FixtureApiClient dispatch rows. Commands answer on the Remote face
* dispatch rows. * and skills on the legacy API face, so both are driven here.
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { SessionId } from '../src/client/api.ts' import type { SessionId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts'
import type { RpcRequest } from '../src/client/api.ts' import type { RpcRequest } from '../src/client/api.ts'
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' import { FixtureApiClient, createFixtureApi, createFixtureFaces } from '../src/client/fixture.ts'
/** Drive one commands Remote endpoint against the fixture state graph. */
async function callRemote<T>(
rpc: ReturnType<typeof createFixtureFaces>['rpc'],
endpoint: string,
args: Record<string, unknown>,
): Promise<T> {
const result = await rpc.call('/api', endpoint, { args })
if (!result.ok) throw new Error(`${endpoint} failed: ${result.error.code}`)
return result.value as T
}
const sid = (id: string): SessionId => id as SessionId const sid = (id: string): SessionId => id as SessionId
let reqCount = 0 let reqCount = 0
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload }) const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
const signal = new AbortController().signal
describe('createFixtureApi commands/skills', () => { describe('createFixtureApi commands/skills', () => {
it('serves the addressed session catalog with rpcId echo', async () => { it('serves the addressed session catalog', async () => {
const api = createFixtureApi() const { rpc } = createFixtureFaces()
const request = req({ sessionId: sid('fx-alpha') }) const commands = await callRemote<{ name: string; input?: { hint: string } }[]>(
const response = await api.commands.list(request) rpc, 'commands/list', { agentId: sid('fx-alpha') })
expect(response.rpcId).toBe(request.rpcId)
if (!response.result.ok) throw new Error('list failed')
const commands = response.result.value.commands
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
// input hint rides only the commands declaring it. // input hint rides only the commands declaring it.
const echo = commands.find(c => c.name === 'echo') const echo = commands.find(c => c.name === 'echo')
@@ -31,13 +38,13 @@ describe('createFixtureApi commands/skills', () => {
}) })
it('rejects a catalog request for an unknown session', async () => { it('rejects a catalog request for an unknown session', async () => {
const api = createFixtureApi() const { rpc } = createFixtureFaces()
const response = await api.commands.list(req({ sessionId: sid('fx-nope') })) const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } })
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
}) })
it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => { it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
const api = createFixtureApi() const { api, rpc } = createFixtureFaces()
const frames: unknown[] = [] const frames: unknown[] = []
const abort = new AbortController() const abort = new AbortController()
const stream = api.events.mux(req({}), abort.signal) const stream = api.events.mux(req({}), abort.signal)
@@ -47,10 +54,9 @@ describe('createFixtureApi commands/skills', () => {
if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
} }
})() })()
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal) const execution = await callRemote<{ commandId: string } | undefined>(
if (!response.result.ok) throw new Error('execute failed') rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hello world' })
expect(response.result.value).toMatchObject({ matched: true }) expect(execution?.commandId).toBeTruthy()
expect(response.result.value.commandId).toBeTruthy()
await pump await pump
const events = frames const events = frames
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event') .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
@@ -63,22 +69,23 @@ describe('createFixtureApi commands/skills', () => {
}) })
it('addresses execute to the session; an unknown session errs', async () => { it('addresses execute to the session; an unknown session errs', async () => {
const api = createFixtureApi() const { rpc } = createFixtureFaces()
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal) const hit = await callRemote<{ commandId: string } | undefined>(
if (!hit.result.ok) throw new Error('execute failed') rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship' })
expect(hit.result.value.matched).toBe(true) expect(hit?.commandId).toBeTruthy()
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal) const missing = await rpc.call('/api', 'commands/execute', {
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) args: { agentId: sid('fx-nope'), line: '/goal ship' },
})
expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
}) })
it('falls to matched:false on unknown names and non-command lines', async () => { it('answers no execution for unknown names and non-command lines', async () => {
const api = createFixtureApi() const { rpc } = createFixtureFaces()
for (const line of ['/nope', 'plain text', '/']) { for (const line of ['/nope', 'plain text', '/']) {
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal) // Absence is the whole answer: nothing matched, so no lifecycle id exists.
if (!response.result.ok) throw new Error('execute failed') expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line }))
// Pure admission value: the matched bit is the whole response shape. .toBeUndefined()
expect(response.result.value).toEqual({ matched: false })
} }
}) })
@@ -94,14 +101,13 @@ describe('createFixtureApi commands/skills', () => {
}) })
describe('FixtureApiClient command/skill dispatch', () => { describe('FixtureApiClient command/skill dispatch', () => {
it('routes the three method keys through the in-memory dispatch table', async () => { it('routes the Remote commands face and the legacy skill row through one state graph', async () => {
const client = new FixtureApiClient() const client = new FixtureApiClient()
const list = await client.commands.list({ sessionId: sid('fx-alpha') }) const commands = await callRemote<{ name: string }[]>(client.rpc, 'commands/list', { agentId: sid('fx-alpha') })
if (!list.result.ok) throw new Error('command.list failed') expect(commands.length).toBeGreaterThan(0)
expect(list.result.value.commands.length).toBeGreaterThan(0) const executed = await callRemote<{ commandId: string } | undefined>(
const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' }) client.rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' })
if (!executed.result.ok) throw new Error('command.execute failed') expect(executed?.commandId).toBeTruthy()
expect(executed.result.value.matched).toBe(true)
const skills = await client.skills.list({ sessionId: sid('fx-alpha') }) const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
if (!skills.result.ok) throw new Error('skill.list failed') if (!skills.result.ok) throw new Error('skill.list failed')
expect(skills.result.value.skills.length).toBeGreaterThan(0) expect(skills.result.value.skills.length).toBeGreaterThan(0)

View File

@@ -34,7 +34,7 @@
"inject": [ "inject": [
"@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry", "@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-api-gateway" "@deepseek-ai/dsh-api-remotes"
], ],
"platform": "web", "platform": "web",
"immediately": true "immediately": true
@@ -59,19 +59,19 @@
"zustand": "~4.4.7" "zustand": "~4.4.7"
}, },
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^"
"@deepseek-ai/cordis": "workspace:^"
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@types/react": "~18.3.1" "@types/react": "~18.3.1"
}, },
"files": [ "files": [

View File

@@ -17,7 +17,7 @@
*/ */
import { Context as CordisContext } from '@deepseek-ai/cordis' import { Context as CordisContext } from '@deepseek-ai/cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta' import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */

View File

@@ -1,5 +1,5 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client' import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client'
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents -- /* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program; * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;

View File

@@ -10,7 +10,8 @@
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { import type {
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId, MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { ConversationSnapshot } from '../sessions/conversation.ts' import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts' import type { ObservableSnapshot } from './store.ts'
@@ -75,9 +76,9 @@ export interface ISession {
* Execute one slash-command line against this session's agent — pure * Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle). * admission semantics (the host executor durably logs the lifecycle).
* @param line - the full command line, leading slash included. * @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure. * @returns the admission result, or the Remote face's error branch.
*/ */
command(line: string): Promise<RpcResult<{ matched: boolean }>> command(line: string): Promise<RemoteResult<{ matched: boolean }>>
} }
/** /**

View File

@@ -7,7 +7,7 @@
* dependency. * dependency.
*/ */
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-api-remotes/client'
import type { ObservableSnapshot } from './store.ts' import type { ObservableSnapshot } from './store.ts'
/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */ /** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */

View File

@@ -10,7 +10,7 @@
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import type { import type {
RpcResult, SessionId, SubagentAddress, RpcResult, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { AgentContext } from '../agents/scope.ts' import type { AgentContext } from '../agents/scope.ts'
import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts'

View File

@@ -6,7 +6,7 @@
* the concrete class. Widening this interface is the explicit act of * the concrete class. Widening this interface is the explicit act of
* widening what features may do to the workspaces domain. * widening what features may do to the workspaces domain.
*/ */
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { WorkspaceListState } from '../workspaces/service.ts' import type { WorkspaceListState } from '../workspaces/service.ts'
import type { ObservableSnapshot } from './store.ts' import type { ObservableSnapshot } from './store.ts'

View File

@@ -1,10 +1,10 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather // Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this // than api-remotes': that face imports a Host-tsdown-generated artifact, and this
// project sits in the Host build graph. // project sits in the Host build graph.
import type {} from '@deepseek-ai/dsh-api-gateway/client' import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts' import { SlotsService } from './slots.ts'
@@ -179,7 +179,7 @@ declare module '@deepseek-ai/cordis' {
} }
/** Required services: the wire handle and Client TypeRT registry. */ /** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert', 'remote'] export const inject = ['connection', 'typert', 'remote', 'remote.commands']
/** Mounts the browser runtime services and connection stream. /** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context. * @param ctx - Client Cordis context.
@@ -191,7 +191,7 @@ export function apply(ctx: Context): void {
views: new ConversationViewRegistry(ctx), views: new ConversationViewRegistry(ctx),
} }
const connection = ctx.get('connection') as ConnectionHandle const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api, conversation) const sessions = new SessionsService(ctx, connection.api, ctx.remote, conversation)
ctx.typert.contexts.registerClient('agent', { ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate), identity: candidate => sessions.scopeOf(candidate),
}) })

View File

@@ -13,7 +13,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type { import type {
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
import type { PendingInteraction } from './pending.ts' import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
import type { import type {

View File

@@ -2,7 +2,7 @@
// The input order is authoritative; lineage only makes each child adjacent to its parent. // The input order is authoritative; lineage only makes each child adjacent to its parent.
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots. // Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { PendingInteractionStatus } from './pending.ts' import type { PendingInteractionStatus } from './pending.ts'

View File

@@ -5,7 +5,7 @@
import type { import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId, SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
// Value import from the inline-safe wire layer (not the connection plugin): // Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error. // plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-session-title/client'
import { Notifier } from './notifier.ts' import { Notifier } from './notifier.ts'
import { ProjectionValueStore } from './projection-store.ts' import { ProjectionValueStore } from './projection-store.ts'
import { Session } from './session.ts' import { Session } from './session.ts'
import type { SessionRemotes } from './remotes.ts'
/** /**
* List arrival lifecycle, orthogonal to the pull-activity `state` axis: * List arrival lifecycle, orthogonal to the pull-activity `state` axis:
@@ -164,6 +165,7 @@ export class SessionManager {
*/ */
constructor( constructor(
private readonly api: IApiClient, private readonly api: IApiClient,
private readonly remote: SessionRemotes,
restoredSelection?: SessionId, restoredSelection?: SessionId,
restoredAddress?: SubagentAddress, restoredAddress?: SubagentAddress,
private readonly conversation?: ConversationRuntime, private readonly conversation?: ConversationRuntime,
@@ -304,7 +306,7 @@ export class SessionManager {
private createSession(sessionId: SessionId): Session { private createSession(sessionId: SessionId): Session {
const address = this.addresses.get(sessionId) const address = this.addresses.get(sessionId)
return new Session(sessionId, this.api, { return new Session(sessionId, this.api, this.remote, {
...(address === undefined ? {} : { ...(address === undefined ? {} : {
address, address,
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false, parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,

View File

@@ -4,7 +4,7 @@
import type { import type {
ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId, ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */ /** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */
export interface PendingPayloads { export interface PendingPayloads {

View File

@@ -1,5 +1,5 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client' import type { MuxFrame } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { QueuedMessage } from './conversation.ts' import type { QueuedMessage } from './conversation.ts'

View File

@@ -0,0 +1,12 @@
/**
* Remote namespaces the Session cluster calls. One parameter for one concept:
* the generated surface a Session and its manager reach the Host through.
*
* @module @deepseek-ai/dsh-client-runtime/client/sessions/remotes
*/
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
/** The generated Remote namespaces a Session and its manager call. */
export type SessionRemotes = Pick<Context['remote'], 'commands'>

View File

@@ -17,7 +17,7 @@
import type { Context, Fiber } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { import type {
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId, IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
// Value import from the inline-safe wire layer (not the connection plugin): // Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error. // plugin-to-plugin value imports are a bundle purity error.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -32,6 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import type { ConversationRuntime } from './conversation-assembler.ts' import type { ConversationRuntime } from './conversation-assembler.ts'
import { SessionManager } from './manager.ts' import { SessionManager } from './manager.ts'
import type { SessionRemotes } from './remotes.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import type { PendingInteractionStatus } from './pending.ts' import type { PendingInteractionStatus } from './pending.ts'
import { SessionProvideChannel } from './provide.ts' import { SessionProvideChannel } from './provide.ts'
@@ -271,11 +272,13 @@ export class SessionsService implements ISessions {
/** /**
* @param ctx - client root context (scope fibers mount under it). * @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session. * @param api - wire client shared with every Session.
* @param remote - generated Remote namespaces shared with every Session.
* @param conversationRuntime - same-pass registry instances, when runtime apply owns them. * @param conversationRuntime - same-pass registry instances, when runtime apply owns them.
*/ */
constructor( constructor(
private readonly rootCtx: Context, private readonly rootCtx: Context,
api: IApiClient, api: IApiClient,
remote: SessionRemotes,
conversationRuntime?: ConversationRuntime, conversationRuntime?: ConversationRuntime,
) { ) {
this.selection = createSnapshotStore<SessionSelection>( this.selection = createSnapshotStore<SessionSelection>(
@@ -291,6 +294,7 @@ export class SessionsService implements ISessions {
) )
this.manager = new SessionManager( this.manager = new SessionManager(
api, api,
remote,
restored.sessionId, restored.sessionId,
restored.subagentAddress, restored.subagentAddress,
conversation, conversation,

View File

@@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError, HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
// Value import from the inline-safe wire layer (not the connection plugin): // Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error. // plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -21,6 +21,8 @@ import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts'
import type { PendingInteraction } from './pending.ts' import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts' import { PendingWait } from './pending.ts'
import { Notifier } from './notifier.ts' import { Notifier } from './notifier.ts'
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { SessionRemotes } from './remotes.ts'
import { ProjectionValueStore } from './projection-store.ts' import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts'
import { resolvedClientTimeZone } from '../time-zone.ts' import { resolvedClientTimeZone } from '../time-zone.ts'
@@ -134,11 +136,13 @@ export class Session implements SessionFace {
/** /**
* @param sessionId - Host session identity (client sessions are always Host-born). * @param sessionId - Host session identity (client sessions are always Host-born).
* @param api - shared wire client. * @param api - shared wire client.
* @param remote - generated Remote namespaces this session calls.
* @param options - optional manager-owned state observers. * @param options - optional manager-owned state observers.
*/ */
constructor( constructor(
readonly sessionId: SessionId, readonly sessionId: SessionId,
private readonly api: IApiClient, private readonly api: IApiClient,
private readonly remote: SessionRemotes,
private readonly options: SessionOptions = {}, private readonly options: SessionOptions = {},
) { ) {
this.projections = options.projections ?? new ProjectionValueStore() this.projections = options.projections ?? new ProjectionValueStore()
@@ -351,12 +355,10 @@ export class Session implements SessionFace {
* @param line - the full command line, leading slash included. * @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure. * @returns the admission result, or the error branch on transport failure.
*/ */
async command(line: string): Promise<RpcResult<{ matched: boolean }>> { async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
try { const result = await this.remote.commands.execute(this.sessionId, line)
return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result if (!result.ok) return result
} catch (error) { return { ok: true, value: { matched: result.value !== undefined } }
return transportError(error)
}
} }
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */

View File

@@ -4,7 +4,7 @@
* uninterrupted subagent subtree. * uninterrupted subagent subtree.
* @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage * @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage
*/ */
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionSummary } from './service.ts' import type { SessionSummary } from './service.ts'
/** Descendant counts projected for one possible parent session. */ /** Descendant counts projected for one possible parent session. */

View File

@@ -2,7 +2,7 @@
import type { import type {
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import { Notifier } from '../sessions/notifier.ts' import { Notifier } from '../sessions/notifier.ts'

View File

@@ -4,7 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type { import type {
DirectoryListing, IApiClient, RpcError, DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '../contract/store.ts' import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts' import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'

View File

@@ -2,7 +2,7 @@
import type { import type {
IApiClient, RpcResult, WorkspaceView, IApiClient, RpcResult, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts' import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from '../sessions/notifier.ts' import { Notifier } from '../sessions/notifier.ts'

View File

@@ -5,8 +5,8 @@
*/ */
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as RuntimeClient from '../src/client/index.ts' import * as RuntimeClient from '../src/client/index.ts'

View File

@@ -1,6 +1,6 @@
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
import type { import type {
@@ -8,7 +8,7 @@ import type {
} from '../src/client/contract/conversation.ts' } from '../src/client/contract/conversation.ts'
import { Session } from '../src/client/sessions/session.ts' import { Session } from '../src/client/sessions/session.ts'
import { SessionsService } from '../src/client/sessions/service.ts' import { SessionsService } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts' import { FakeApiClient, fakeRemote, ok } from './fake-api.ts'
function eventDefinition(kind: string): ConversationNodeDefinition<null> { function eventDefinition(kind: string): ConversationNodeDefinition<null> {
return { return {
@@ -145,7 +145,7 @@ describe('Conversation registries', () => {
api.onList = () => Promise.resolve(ok({ api.onList = () => Promise.resolve(ok({
items: [{ sessionId, updatedAt: 1, running: false, blank: true }], items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
}) as never) }) as never)
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
await sessions.refresh() await sessions.refresh()
await Promise.resolve() await Promise.resolve()
sessions.scope(sessionId) sessions.scope(sessionId)

View File

@@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client' import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client'
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts' import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
describe('toAssistantBlock', () => { describe('toAssistantBlock', () => {

View File

@@ -1,13 +1,13 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and // data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame, ClientResponse, HostFrame, IApiClient, ModelSelection, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-api-remotes/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
/** Programmable-default workspace row (branded id, ISO-ish times). */ /** Programmable-default workspace row (branded id, ISO-ish times). */
function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView { function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
@@ -55,6 +55,20 @@ interface StreamConn<F> {
feed(item: StreamItem<F>): void feed(item: StreamItem<F>): void
} }
/**
* Commands Remote double: the generated face delivers the carrier's outcome, so
* a test that programs nothing sees an empty catalog and an unmatched line.
* @returns the Remote namespaces the session cluster calls.
*/
export function fakeRemote(): SessionRemotes {
return {
commands: {
list: () => Promise.resolve({ ok: true, value: [] }),
execute: () => Promise.resolve({ ok: true, value: undefined }),
},
}
}
export class FakeApiClient implements IApiClient { export class FakeApiClient implements IApiClient {
/** Chronological call record: [method, payload]. */ /** Chronological call record: [method, payload]. */
readonly calls: { method: string; payload: unknown }[] = [] readonly calls: { method: string; payload: unknown }[] = []
@@ -205,19 +219,10 @@ export class FakeApiClient implements IApiClient {
// Payloads stay `unknown` (lint-lane note above); response rows are the real // Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program requires-bearing catalogs and dual-address // wire shapes so cases can program requires-bearing catalogs and dual-address
// skill lists without casts. // skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] })) = () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly agentPresets: IApiClient['agentPresets'] = { readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
select: (payload: { agentPreset: string }) => select: (payload: { agentPreset: string }) =>

View File

@@ -4,7 +4,7 @@
*/ */
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
import { flattenLineage } from '../src/client/sessions/lineage.ts' import { flattenLineage } from '../src/client/sessions/lineage.ts'
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({

View File

@@ -4,9 +4,9 @@
*/ */
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionManager } from '../src/client/sessions/manager.ts' import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts' import { entries, plainTurn } from './event-script.ts'
const S1 = 'fk-m1' as SessionId const S1 = 'fk-m1' as SessionId
@@ -28,7 +28,7 @@ describe('instances', () => {
it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshList() await manager.refreshList()
const session = manager.get(S1) const session = manager.get(S1)
expect(manager.get(S1)).toBe(session) // resident: same instance forever expect(manager.get(S1)).toBe(session) // resident: same instance forever
@@ -37,7 +37,7 @@ describe('instances', () => {
it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => { it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
// Uninstantiated: approval buffers, plain session/event drops. // Uninstantiated: approval buffers, plain session/event drops.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
@@ -50,7 +50,7 @@ describe('instances', () => {
it('retains every live answerable request and compacts resolutions before instantiation', () => { it('retains every live answerable request and compacts resolutions before instantiation', () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
for (let i = 0; i < 40; i++) { for (let i = 0; i < 40; i++) {
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
@@ -67,7 +67,7 @@ describe('instances', () => {
}) })
it('drops buffered answerable requests on session removal', () => { it('drops buffered answerable requests on session removal', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
// Removed session: buffered frames must not replay on a future instantiation. // Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
@@ -80,7 +80,7 @@ describe('list lifecycle', () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>() const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise api.onList = () => gate.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const first = manager.refreshList() const first = manager.refreshList()
const second = manager.refreshList() const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading') expect(manager.getListSnapshot().state).toBe('loading')
@@ -96,7 +96,7 @@ describe('list lifecycle', () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>() const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => first.promise api.onList = () => first.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const hydration = manager.refreshList() const hydration = manager.refreshList()
manager.handleHostEnvelope({ manager.handleHostEnvelope({
rpcId: 'during-first' as never, rpcId: 'during-first' as never,
@@ -116,7 +116,7 @@ describe('list lifecycle', () => {
it('keeps the error in the list snapshot on failure', async () => { it('keeps the error in the list snapshot on failure', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshList() await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
// A failed pull does not step the arrival phase: still pending. // A failed pull does not step the arrival phase: still pending.
@@ -125,7 +125,7 @@ describe('list lifecycle', () => {
it('phase steps pending → ready on the first successful pull and never returns', async () => { it('phase steps pending → ready on the first successful pull and never returns', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
expect(manager.getListSnapshot().phase).toBe('pending') expect(manager.getListSnapshot().phase).toBe('pending')
await manager.refreshList() await manager.refreshList()
expect(manager.getListSnapshot().phase).toBe('ready') expect(manager.getListSnapshot().phase).toBe('ready')
@@ -144,7 +144,7 @@ describe('list lifecycle', () => {
it('merges create into the list immediately without waiting for a refresh', async () => { it('merges create into the list immediately without waiting for a refresh', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const result = await manager.create() const result = await manager.create()
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
@@ -152,7 +152,7 @@ describe('list lifecycle', () => {
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const titleFrame = (rpcId: string, title: string, seq: number) => { const titleFrame = (rpcId: string, title: string, seq: number) => {
manager.handleMuxEnvelope({ manager.handleMuxEnvelope({
rpcId: rpcId as never, rpcId: rpcId as never,
@@ -179,7 +179,7 @@ describe('list lifecycle', () => {
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
// A push frame landed before the list (S2's title is newer than the block's cut). // A push frame landed before the list (S2's title is newer than the block's cut).
manager.handleMuxEnvelope({ manager.handleMuxEnvelope({
rpcId: 'push-newer' as never, rpcId: 'push-newer' as never,
@@ -202,7 +202,7 @@ describe('list lifecycle', () => {
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshList() await manager.refreshList()
const frame = (rpcId: string, payload: object) => { const frame = (rpcId: string, payload: object) => {
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never }) manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
@@ -230,7 +230,7 @@ describe('search', () => {
items: [{ sessionId: S1, snippet: 'matching excerpt' }], items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true, hasMore: true,
})) }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const signal = new AbortController().signal const signal = new AbortController().signal
await expect(manager.search('exact phrase', signal)).resolves.toEqual({ await expect(manager.search('exact phrase', signal)).resolves.toEqual({
@@ -246,7 +246,7 @@ describe('search', () => {
it('preserves business errors and folds transport failures', async () => { it('preserves business errors and folds transport failures', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
api.onSearch = () => Promise.resolve(err({ api.onSearch = () => Promise.resolve(err({
code: 'internal', code: 'internal',
message: 'index unavailable', message: 'index unavailable',
@@ -269,7 +269,7 @@ describe('search', () => {
describe('host frame routing', () => { describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored
expect(manager.getListSnapshot().items).toHaveLength(1) expect(manager.getListSnapshot().items).toHaveLength(1)
@@ -303,7 +303,7 @@ describe('subagent catalogs', () => {
}] as never[], }] as never[],
parentAvailable: true, parentAvailable: true,
})) }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshList() await manager.refreshList()
await manager.refreshSubagents(S1) await manager.refreshSubagents(S1)
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
@@ -368,7 +368,7 @@ describe('subagent catalogs', () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshSubagents(S1) await manager.refreshSubagents(S1)
manager.setSubagentCatalogOpen(S1, true) manager.setSubagentCatalogOpen(S1, true)
await Promise.resolve() await Promise.resolve()
@@ -418,7 +418,7 @@ describe('subagent catalogs', () => {
] as never[], ] as never[],
parentAvailable: true, parentAvailable: true,
})) }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshSubagents(root) await manager.refreshSubagents(root)
manager.handleHostEnvelope({ manager.handleHostEnvelope({
@@ -447,7 +447,7 @@ describe('subagent catalogs', () => {
const root = 'fk-root' as SessionId const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>() const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise api.onSubagentList = () => response.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshSubagents(root) const refresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({ manager.handleHostEnvelope({
@@ -488,7 +488,7 @@ describe('subagent catalogs', () => {
const root = 'fk-root' as SessionId const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>() const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise api.onSubagentList = () => response.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshSubagents(root) const refresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({ manager.handleHostEnvelope({
@@ -529,7 +529,7 @@ describe('subagent catalogs', () => {
}] as never[], }] as never[],
parentAvailable: true, parentAvailable: true,
})) }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshSubagents(S1) await manager.refreshSubagents(S1)
manager.handleHostEnvelope({ manager.handleHostEnvelope({
@@ -547,7 +547,7 @@ describe('subagent catalogs', () => {
const root = 'fk-root' as SessionId const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>() const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise api.onSubagentList = () => first.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshSubagents(root) const refresh = manager.refreshSubagents(root)
expect(manager.refreshSubagents(root)).toBe(refresh) expect(manager.refreshSubagents(root)).toBe(refresh)
@@ -566,7 +566,7 @@ describe('subagent catalogs', () => {
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>() const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>() const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise api.onSubagentList = () => first.promise
const manager = new SessionManager(api, root) const manager = new SessionManager(api, fakeRemote(), root)
const refresh = manager.refreshSubagents(root) const refresh = manager.refreshSubagents(root)
// A membership frame arrives while the pull is in flight; the debounced // A membership frame arrives while the pull is in flight; the debounced
@@ -624,7 +624,7 @@ describe('subagent catalogs', () => {
}) })
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>() const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise api.onSubagentList = () => first.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshSubagents(root) const refresh = manager.refreshSubagents(root)
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await refresh await refresh
@@ -671,7 +671,7 @@ describe('subagent catalogs', () => {
}] as never[], }] as never[],
parentAvailable: true, parentAvailable: true,
})) }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshSubagents(root) await manager.refreshSubagents(root)
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
@@ -690,14 +690,14 @@ describe('remaining branches', () => {
it('refreshList folds a transport throw into the error state', async () => { it('refreshList folds a transport throw into the error state', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onList = () => Promise.reject(new Error('list wire down')) api.onList = () => Promise.reject(new Error('list wire down'))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshList() await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
}) })
it('refreshList pushes running bits down to already-instantiated sessions', async () => { it('refreshList pushes running bits down to already-instantiated sessions', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const session = manager.get(S1) const session = manager.get(S1)
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
await manager.refreshList() await manager.refreshList()
@@ -707,7 +707,7 @@ describe('remaining branches', () => {
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.create({ cwd: '/tmp/w', sessionId: S1 }) await manager.create({ cwd: '/tmp/w', sessionId: S1 })
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
@@ -727,7 +727,7 @@ describe('remaining branches', () => {
message: 'published but unattached', message: 'published but unattached',
details: { sessionId: S1, workspaceId: 'w1' }, details: { sessionId: S1, workspaceId: 'w1' },
} as never)) } as never))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
@@ -741,7 +741,7 @@ describe('remaining branches', () => {
message: 'forked but unattached', message: 'forked but unattached',
details: { sessionId: S2, workspaceId: 'w1' }, details: { sessionId: S2, workspaceId: 'w1' },
} as never)) } as never))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const result = await manager.fork({ sessionId: S1 }) const result = await manager.fork({ sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
@@ -754,7 +754,7 @@ describe('remaining branches', () => {
it('reconciles a preallocated id after an ordinary transport failure', async () => { it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost')) api.onCreate = () => Promise.reject(new Error('response lost'))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
expect(manager.getListSnapshot().items).toEqual([]) expect(manager.getListSnapshot().items).toEqual([])
@@ -775,7 +775,7 @@ describe('remaining branches', () => {
it('subscribe notifies on list changes and stops after unsubscribe', async () => { it('subscribe notifies on list changes and stops after unsubscribe', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
let notified = 0 let notified = 0
const unsubscribe = manager.subscribe(() => { notified++ }) const unsubscribe = manager.subscribe(() => { notified++ })
await manager.refreshList() await manager.refreshList()
@@ -790,7 +790,7 @@ describe('remaining branches', () => {
it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => { it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never }) manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never })
@@ -805,7 +805,7 @@ describe('remaining branches', () => {
it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshList() await manager.refreshList()
const before = manager.getListSnapshot() const before = manager.getListSnapshot()
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
@@ -821,7 +821,7 @@ describe('remaining branches', () => {
it('carries parentSessionId from host/session-added into the lineage row', () => { it('carries parentSessionId from host/session-added into the lineage row', () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ manager.handleHostEnvelope({
rpcId: 'h2' as never, rpcId: 'h2' as never,
@@ -845,7 +845,7 @@ describe('connected generation', () => {
hasMore: false, hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
})) }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const openedSession = manager.get(S1) const openedSession = manager.get(S1)
await openedSession.open() await openedSession.open()
manager.get(S2) // instantiated but never opened manager.get(S2) // instantiated but never opened
@@ -863,7 +863,7 @@ describe('connected generation', () => {
const address = { const address = {
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
} }
const manager = new SessionManager(api, S2, address) const manager = new SessionManager(api, fakeRemote(), S2, address)
manager.handleConnected() manager.handleConnected()
@@ -876,7 +876,7 @@ describe('connected generation', () => {
describe('pending-interaction list status', () => { describe('pending-interaction list status', () => {
it('tracks approval requests through replay and resolution without instantiation', () => { it('tracks approval requests through replay and resolution without instantiation', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
@@ -889,7 +889,7 @@ describe('pending-interaction list status', () => {
}) })
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ manager.handleMuxEnvelope({
rpcId: 'q1' as never, rpcId: 'q1' as never,
@@ -922,7 +922,7 @@ describe('pending-interaction list status', () => {
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ manager.handleMuxEnvelope({
rpcId: 'q-plan' as never, rpcId: 'q-plan' as never,
@@ -939,7 +939,7 @@ describe('pending-interaction list status', () => {
}) })
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ manager.handleMuxEnvelope({
@@ -958,7 +958,7 @@ describe('pending-interaction list status', () => {
}) })
it('drops stale status at generation death before replay re-adds live interactions', () => { it('drops stale status at generation death before replay re-adds live interactions', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
@@ -973,7 +973,7 @@ describe('pending-interaction list status', () => {
}) })
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
// Buffered pre-instantiation: an approval pair and a queued row. // Buffered pre-instantiation: an approval pair and a queued row.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
@@ -1000,7 +1000,7 @@ describe('completed reminder', () => {
manager.getListSnapshot().items.find(item => item.sessionId === sessionId) manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
it('arms on a running→idle flip of a non-selected session and clears on select', () => { it('arms on a running→idle flip of a non-selected session and clears on select', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2)) manager.handleHostEnvelope(added('h2', S2))
manager.select(S1) manager.select(S1)
@@ -1014,7 +1014,7 @@ describe('completed reminder', () => {
}) })
it('never arms for the session being watched and re-arms after a switch-away re-run', () => { it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2)) manager.handleHostEnvelope(added('h2', S2))
manager.select(S2) manager.select(S2)
@@ -1029,7 +1029,7 @@ describe('completed reminder', () => {
}) })
it('a re-run disarms the reminder while running and re-arms on its completion', () => { it('a re-run disarms the reminder while running and re-arms on its completion', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2)) manager.handleHostEnvelope(added('h2', S2))
manager.select(S1) manager.select(S1)
@@ -1044,7 +1044,7 @@ describe('completed reminder', () => {
}) })
it('session-removed drops the reminder and a re-add starts clean', () => { it('session-removed drops the reminder and a re-add starts clean', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2)) manager.handleHostEnvelope(added('h2', S2))
manager.select(S1) manager.select(S1)
@@ -1060,7 +1060,7 @@ describe('completed reminder', () => {
it('a list refresh carrying the running→idle transition arms the reminder', async () => { it('a list refresh carrying the running→idle transition arms the reminder', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshList() await manager.refreshList()
manager.select(S1) manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false) expect(entry(manager, S2)?.completed).toBe(false)
@@ -1072,7 +1072,7 @@ describe('completed reminder', () => {
it('never arms for sessions already idle at first observation', async () => { it('never arms for sessions already idle at first observation', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
await manager.refreshList() await manager.refreshList()
manager.select(S1) manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false) expect(entry(manager, S2)?.completed).toBe(false)
@@ -1085,7 +1085,7 @@ describe('completed reminder', () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>() const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise api.onList = () => gate.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshList() const refresh = manager.refreshList()
// The session finishes while the first pull is still in flight; the pull // The session finishes while the first pull is still in flight; the pull
// response recorded it as running at pull time. // response recorded it as running at pull time.
@@ -1099,7 +1099,7 @@ describe('completed reminder', () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>() const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise api.onList = () => gate.promise
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
const refresh = manager.refreshList() const refresh = manager.refreshList()
// The unknown session starts and finishes while the first pull is in // The unknown session starts and finishes while the first pull is in
// flight; the pull-time baseline recorded it idle, so the running→idle // flight; the pull-time baseline recorded it idle, so the running→idle
@@ -1120,7 +1120,7 @@ describe('background-task mirror', () => {
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never }) ({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
const first = manager.getListSnapshot().tasksBySession const first = manager.getListSnapshot().tasksBySession
@@ -1133,7 +1133,7 @@ describe('background-task mirror', () => {
}) })
it('stores an emptied set as an absent key so absence and [] read alike', () => { it('stores an emptied set as an absent key so absence and [] read alike', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true) expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
manager.handleMuxEnvelope(tasksFrame(S1, [])) manager.handleMuxEnvelope(tasksFrame(S1, []))
@@ -1141,7 +1141,7 @@ describe('background-task mirror', () => {
}) })
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => { it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope({ manager.handleMuxEnvelope({
rpcId: 's' as never, rpcId: 's' as never,
@@ -1151,7 +1151,7 @@ describe('background-task mirror', () => {
}) })
it('drops the rows when the session is removed, whichever stream lands first', () => { it('drops the rows when the session is removed, whichever stream lands first', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
@@ -1159,7 +1159,7 @@ describe('background-task mirror', () => {
}) })
it('notifies list subscribers so an open header re-renders without a poll', async () => { it('notifies list subscribers so an open header re-renders without a poll', async () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
const seen = vi.fn() const seen = vi.fn()
manager.subscribe(seen) manager.subscribe(seen)
manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope(tasksFrame(S1, [view()]))

View File

@@ -4,7 +4,7 @@
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-client-connection/client' import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client'
import { PartialAccumulator } from '../src/client/sessions/partial.ts' import { PartialAccumulator } from '../src/client/sessions/partial.ts'
const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk

View File

@@ -8,11 +8,11 @@
* list rows' title projection). * list rows' title projection).
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
import { Session } from '../src/client/sessions/session.ts' import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts' import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, ok } from './fake-api.ts' import { FakeApiClient, fakeRemote, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts' import { entries, plainTurn } from './event-script.ts'
// Test-domain keys merged into the projection map (the Service Definition package's // Test-domain keys merged into the projection map (the Service Definition package's
@@ -103,7 +103,7 @@ describe('ProjectionValueStore semantics', () => {
describe('Session tail-page seeding', () => { describe('Session tail-page seeding', () => {
it('seeds the store from a history response carrying a projections block', async () => { it('seeds the store from a history response carrying a projections block', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const session = new Session(SID, api) const session = new Session(SID, api, fakeRemote())
api.onHistory = () => Promise.resolve(ok({ api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
@@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => {
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const session = new Session(SID, api) const session = new Session(SID, api, fakeRemote())
api.onHistory = () => Promise.resolve(ok({ api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
@@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => {
it('treats a blockless response as no reset: pushed values survive', async () => { it('treats a blockless response as no reset: pushed values survive', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const session = new Session(SID, api) const session = new Session(SID, api, fakeRemote())
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await session.open() await session.open()
session.projections.apply('test/marks', { marks: ['pushed'] }, 9) session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
@@ -141,7 +141,7 @@ describe('manager frame routing', () => {
it('lands session/projection frames before instantiation and the Session adopts the same store', async () => { it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
manager.handleMuxEnvelope({ manager.handleMuxEnvelope({
rpcId: 'p1' as never, rpcId: 'p1' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never, payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
@@ -158,7 +158,7 @@ describe('manager frame routing', () => {
it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => { it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
api.onList = () => Promise.resolve(ok({ api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never) }) as never)
@@ -181,7 +181,7 @@ describe('manager frame routing', () => {
it('projects every retained value into list rows with stable snapshot identity', async () => { it('projects every retained value into list rows with stable snapshot identity', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
api.onList = () => Promise.resolve(ok({ api.onList = () => Promise.resolve(ok({
items: [{ items: [{
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
@@ -211,7 +211,7 @@ describe('manager frame routing', () => {
it('drops the projection store with the removed session', async () => { it('drops the projection store with the removed session', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new SessionManager(api) const manager = new SessionManager(api, fakeRemote())
api.onList = () => Promise.resolve(ok({ api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never) }) as never)

View File

@@ -7,10 +7,10 @@ import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm' import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { Session } from '../src/client/sessions/session.ts' import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts' import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient } from './fake-api.ts' import { FakeApiClient, fakeRemote } from './fake-api.ts'
const SID = 'fk-q1' as SessionId const SID = 'fk-q1' as SessionId
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
@@ -42,7 +42,7 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
} }
function makeSession(): Session { function makeSession(): Session {
return new Session(SID, new FakeApiClient()) return new Session(SID, new FakeApiClient(), fakeRemote())
} }
describe('queue snapshot intake', () => { describe('queue snapshot intake', () => {
@@ -198,7 +198,7 @@ describe('queue snapshot intake', () => {
describe('queue operation transport', () => { describe('queue operation transport', () => {
it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const session = new Session(SID, api) const session = new Session(SID, api, fakeRemote())
session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }])) session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
const before = session.getSnapshot().queue const before = session.getSnapshot().queue
@@ -251,14 +251,14 @@ describe('queue reconnect semantics', () => {
describe('manager buffering of queue snapshots', () => { describe('manager buffering of queue snapshots', () => {
it('replays only the latest snapshot for an uninstantiated session', () => { it('replays only the latest snapshot for an uninstantiated session', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) }) manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) }) manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
}) })
it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => { it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
const manager = new SessionManager(new FakeApiClient()) const manager = new SessionManager(new FakeApiClient(), fakeRemote())
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) }) manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
manager.handleMuxEnvelope({ manager.handleMuxEnvelope({
rpcId: rid('g1b'), rpcId: rid('g1b'),

View File

@@ -8,7 +8,7 @@
*/ */
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { createScope, scopeOf } from '../src/client/agents/scope.ts' import { createScope, scopeOf } from '../src/client/agents/scope.ts'
const sid = (k: string): SessionId => k as SessionId const sid = (k: string): SessionId => k as SessionId

View File

@@ -9,7 +9,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-commands/types' import type {} from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { Session } from '../src/client/sessions/session.ts' import { Session } from '../src/client/sessions/session.ts'
import type { import type {
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
@@ -17,7 +17,7 @@ import type {
ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot, ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot,
ConversationViewDefinition, ConversationViewDefinition,
} from '../src/client/index.ts' } from '../src/client/index.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts' import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'fk-s1' as SessionId const SID = 'fk-s1' as SessionId
@@ -159,7 +159,7 @@ const TEST_CONVERSATION: ConversationRuntime = {
} }
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) } return { api, session: new Session(SID, api, fakeRemote(), { conversation: TEST_CONVERSATION }) }
} }
function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] { function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] {
@@ -343,7 +343,7 @@ describe('live event path', () => {
entries: () => [testViewDefinition()], entries: () => [testViewDefinition()],
} as unknown as ConversationRuntime['views'], } as unknown as ConversationRuntime['views'],
} }
const session = new Session(SID, api, { conversation }) const session = new Session(SID, api, fakeRemote(), { conversation })
await session.open() await session.open()
const snapshots: ConversationSnapshot[] = [] const snapshots: ConversationSnapshot[] = []
session.subscribe(() => { snapshots.push(session.getSnapshot()) }) session.subscribe(() => { snapshots.push(session.getSnapshot()) })
@@ -448,7 +448,7 @@ describe('paging', () => {
describe('prompt and cancel errors', () => { describe('prompt and cancel errors', () => {
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const session = new Session(SID, api, { const session = new Session(SID, api, fakeRemote(), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true, parentAvailable: true,
}) })
@@ -487,7 +487,7 @@ describe('prompt and cancel errors', () => {
api.onSubagentInterrupt = () => Promise.resolve(err({ api.onSubagentInterrupt = () => Promise.resolve(err({
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
}) as never) }) as never)
const session = new Session(SID, api, { const session = new Session(SID, api, fakeRemote(), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true, parentAvailable: true,
}) })
@@ -501,7 +501,7 @@ describe('prompt and cancel errors', () => {
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const session = new Session(SID, api, { const session = new Session(SID, api, fakeRemote(), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
}) })
await session.open() await session.open()

View File

@@ -8,9 +8,9 @@
*/ */
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId const sid = (s: string): SessionId => s as SessionId
@@ -23,7 +23,7 @@ interface Bench {
function bench(): Bench { function bench(): Bench {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const svc = new SessionsService(ctx, api) const svc = new SessionsService(ctx, api, fakeRemote())
return { ctx, api, svc } return { ctx, api, svc }
} }

View File

@@ -6,7 +6,7 @@
*/ */
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
// Type-only: the api-remotes facade carries both the allowlist's selection seat // Type-only: the api-remotes facade carries both the allowlist's selection seat
// and the owner packages' `./types` declarations, which together give `$on` its // and the owner packages' `./types` declarations, which together give `$on` its

View File

@@ -1,10 +1,10 @@
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionsService } from '../src/client/sessions/service.ts' import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId const sid = (id: string): SessionId => id as SessionId
const wid = (id: string): WorkspaceId => id as WorkspaceId const wid = (id: string): WorkspaceId => id as WorkspaceId
@@ -124,7 +124,7 @@ describe('WorkspacesService', () => {
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => { it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ api.onWorkspaceList = () => Promise.resolve(ok({
items: [ items: [
@@ -152,7 +152,7 @@ describe('WorkspacesService', () => {
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => { it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[], items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
@@ -211,7 +211,7 @@ describe('WorkspacesService', () => {
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => { it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] })) api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
api.onList = () => Promise.resolve(ok({ api.onList = () => Promise.resolve(ok({
@@ -231,7 +231,7 @@ describe('WorkspacesService', () => {
it('returns created Workspaces and preserves Host business errors', async () => { it('returns created Workspaces and preserves Host business errors', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceCreate = () => Promise.resolve(ok({ api.onWorkspaceCreate = () => Promise.resolve(ok({
workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true, workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
@@ -250,7 +250,7 @@ describe('WorkspacesService', () => {
it('passes native directory selection and cancellation through without local state', async () => { it('passes native directory selection and cancellation through without local state', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha') await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
@@ -264,7 +264,7 @@ describe('WorkspacesService', () => {
it('passes listings and creation through the browse wire, wrapping business failures', async () => { it('passes listings and creation through the browse wire, wrapping business failures', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api)) const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote()))
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false } const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
api.onListDirectory = () => Promise.resolve(ok(listing)) api.onListDirectory = () => Promise.resolve(ok(listing))
await expect(workspaces.listDirectory()).resolves.toEqual(listing) await expect(workspaces.listDirectory()).resolves.toEqual(listing)
@@ -285,7 +285,7 @@ describe('WorkspacesService', () => {
it('opens a filesystem path through the host without local state', async () => { it('opens a filesystem path through the host without local state', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined() await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }]) expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
@@ -296,7 +296,7 @@ describe('WorkspacesService', () => {
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
await workspaces.refresh() await workspaces.refresh()
@@ -312,7 +312,7 @@ describe('WorkspacesService', () => {
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => { it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
api.onList = () => Promise.resolve(ok({ api.onList = () => Promise.resolve(ok({
items: [ items: [
@@ -358,7 +358,7 @@ describe('WorkspacesService', () => {
it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => { it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
api.onList = () => Promise.resolve(ok({ api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }], items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
@@ -392,7 +392,7 @@ describe('startInitialSelection', () => {
function bench() { function bench() {
const ctx = new Context() const ctx = new Context()
const api = new FakeApiClient() const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api) const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions) const workspaces = new WorkspacesService(ctx, api, sessions)
return { api, sessions, workspaces } return { api, sessions, workspaces }
} }

View File

@@ -20,9 +20,6 @@
{ {
"path": "../web-react" "path": "../web-react"
}, },
{
"path": "../connection"
},
{ {
"path": "../../host/apiproxy" "path": "../../host/apiproxy"
}, },
@@ -60,7 +57,7 @@
"path": "../../typert/registry" "path": "../../typert/registry"
}, },
{ {
"path": "../../api/gateway" "path": "../../api/remotes/tsconfig.client.json"
} }
], ],
"exclude": [ "exclude": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ import {
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { SessionInputShell } from '../src/client/input/facade.ts' import { SessionInputShell } from '../src/client/input/facade.ts'
@@ -98,7 +98,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
api.onList = () => Promise.resolve(ok({ api.onList = () => Promise.resolve(ok({
items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }], items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
}) as never) }) as never)
const sessions = new SessionsService(ctx, api) // provides 'sessions' itself const sessions = new SessionsService(ctx, api, fakeRemote()) // provides 'sessions' itself
await sessions.refresh() await sessions.refresh()
await Promise.resolve() // manager notifier flush await Promise.resolve() // manager notifier flush
await ctx.plugin(SlashService).await() await ctx.plugin(SlashService).await()

View File

@@ -17,9 +17,6 @@
{ {
"path": "../../../vendor/cordis" "path": "../../../vendor/cordis"
}, },
{
"path": "../connection"
},
{ {
"path": "../ui-slots" "path": "../ui-slots"
}, },

View File

@@ -32,7 +32,7 @@
"dsh": { "dsh": {
"client": { "client": {
"inject": [ "inject": [
"@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation" "@deepseek-ai/dsh-client-ui-conversation"
], ],
@@ -45,7 +45,7 @@
}, },
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -57,7 +57,7 @@
"react": "^18.2.0" "react": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^",

View File

@@ -7,7 +7,7 @@
* projection pair through the standard-kit `useProjection`; zero client-side * projection pair through the standard-kit `useProjection`; zero client-side
* plan state. * plan state.
*/ */
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat). // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -39,8 +39,8 @@ export interface PlanChipInjected {
exitPlanMode: () => Promise<string | null> exitPlanMode: () => Promise<string | null>
} }
/** Required services: the seat's slot registry, transport, and locale registry. */ /** Required services: the seat's slot registry, commands Remote, and locale registry. */
export const inject = ['slots', 'connection', 'locale'] export const inject = ['slots', 'remote', 'remote.commands', 'locale']
/** /**
* Client plugin body: register the plan chip over the command channel. * Client plugin body: register the plan chip over the command channel.
@@ -55,10 +55,9 @@ export function apply(ctx: ClientContext): void {
inject: (sessionId: SessionId): PlanChipInjected => ({ inject: (sessionId: SessionId): PlanChipInjected => ({
// Failure strings stay English (error-surface policy: not localized). // Failure strings stay English (error-surface policy: not localized).
exitPlanMode: async () => { exitPlanMode: async () => {
const connection = ctx.get('connection') as ConnectionHandle const result = await ctx.remote.commands.execute(sessionId, '/plan off')
const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
if (!result.ok) return `${result.error.message} (${result.error.code})` if (!result.ok) return `${result.error.message} (${result.error.code})`
if (!result.value.matched) return 'unknown command: /plan off' if (result.value === undefined) return 'unknown command: /plan off'
return null return null
}, },
}), }),

View File

@@ -25,16 +25,18 @@ async function bench() {
name: 'root', name: 'root',
children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } }, children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
} as never, () => null) } as never, () => null)
const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) => const execute = vi.fn((_sessionId: SessionId, _line: string) =>
Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } })) Promise.resolve({ commandId: 'c1', result: { kind: 'success' as const } }))
ctx.provide('connection', { api: { commands: { execute } } }) const commandsRemote = { execute }
ctx.provide('remote', { commands: commandsRemote })
ctx.provide('remote.commands', commandsRemote)
ctx.provide('locale', new LocaleService(ctx)) ctx.provide('locale', new LocaleService(ctx))
return { ctx, slots, execute } return { ctx, slots, execute }
} }
describe('ui-plan browser apply', () => { describe('ui-plan browser apply', () => {
it('declares every service it binds', () => { it('declares every service it binds', () => {
expect(inject).toEqual(['slots', 'connection', 'locale']) expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'locale'])
}) })
it('node-half apply is an intentional no-op', () => { it('node-half apply is an intentional no-op', () => {
@@ -44,7 +46,8 @@ describe('ui-plan browser apply', () => {
it('waits until conversation declares the plan seat', async () => { it('waits until conversation declares the plan seat', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SlotsService).await() await ctx.plugin(SlotsService).await()
ctx.provide('connection', {}) ctx.provide('remote', { commands: {} })
ctx.provide('remote.commands', {})
ctx.provide('locale', new LocaleService(ctx)) ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply }) const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await() await fiber.await()
@@ -65,18 +68,17 @@ describe('ui-plan browser apply', () => {
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
await expect(injected.exitPlanMode()).resolves.toBeNull() await expect(injected.exitPlanMode()).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off')
// Business failure folds to the composer-visible line. // Business failure folds to the composer-visible line: the generated method
b.execute.mockResolvedValueOnce({ // throws with the RPC failure as its cause.
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, b.execute.mockRejectedValueOnce(new Error('client api: commands/execute failed', {
} as never) cause: { code: 'session-not-found', message: 'gone', details: {} },
}))
await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)') await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)')
// Unmatched admission (plan-mode not composed host-side) is also a failure line. // Unmatched admission (plan-mode not composed host-side) is also a failure line.
b.execute.mockResolvedValueOnce({ b.execute.mockResolvedValueOnce(undefined as never)
result: { ok: true as const, value: { matched: false as const } },
} as never)
await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off') await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off')
await fiber.dispose() await fiber.dispose()

View File

@@ -8,15 +8,15 @@
"src" "src"
], ],
"references": [ "references": [
{
"path": "../../api/remotes/tsconfig.client.json"
},
{ {
"path": "../../../vendor/cordis" "path": "../../../vendor/cordis"
}, },
{ {
"path": "../runtime" "path": "../runtime"
}, },
{
"path": "../connection"
},
{ {
"path": "../locale" "path": "../locale"
}, },

View File

@@ -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. // GoalError narrows domain rejections to their stable codes at the wire boundary.
import { GoalError } from '@deepseek-ai/dsh-goal' import { GoalError } from '@deepseek-ai/dsh-goal'
import type { GoalRef as CoreGoalRef } 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-commands'
import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-skill'
// The settings/credentials seams: brand guards run at this wire boundary; the // The settings/credentials seams: brand guards run at this wire boundary; the
@@ -2889,49 +2889,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}, },
}, },
commands: {
// Both methods address one session's agent. agentFor resumes on miss
// and fences every subagent-owned identity with `agent-busy`; the
// api/commands.ts module contract owns that fence's wording, so this
// comment only notes the routing shape: clients send a sessionId for a
// published session, and resume restores an existing entity.
async list(request) {
// Missing service = the deployment omitted dsh-commands from its
// composition, not an empty catalog: fail loud instead of serving [].
const commands = ctx.get('commands')
if (commands === undefined) {
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
}
const found = await agentFor(request.payload.sessionId)
if ('error' in found) return err(request, found.error)
return ok(request, { commands: commands.list(found.agent) })
},
async execute(request, signal) {
const commands = ctx.get('commands')
if (commands === undefined) {
return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
}
const { sessionId, line } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
try {
// Pure admission: the executor's durable command/run + command/done
// pair (broadcast on the mux stream) carries the outcome; the
// response reports whether the line resolved to a handler, plus the
// minted pairing id so the issuing client can correlate its request
// with the flow node the lifecycle events produce.
const execution = await commands.execute(found.agent, line, signal)
return ok(request, execution === undefined
? { matched: false }
: { matched: true, commandId: execution.commandId })
} catch (error: unknown) {
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
}
},
},
goals: { goals: {
// Mutations only — the read side is the 'goal' session projection. // Mutations only — the read side is the 'goal' session projection.
// Every verb resolves the session's agent (agentFor: implicit cold // Every verb resolves the session's agent (agentFor: implicit cold

View File

@@ -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'>>>

View File

@@ -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 }>>
}

View File

@@ -7,7 +7,6 @@
import type { SessionsApi } from './sessions.ts' import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts' import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts' import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { AgentPresetsApi } from './agent-presets.ts' import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts' import type { SkillsApi } from './skills.ts'
import type { SubagentsApi } from './subagents.ts' import type { SubagentsApi } from './subagents.ts'
@@ -25,7 +24,6 @@ export interface ApiProxy {
subagents: SubagentsApi subagents: SubagentsApi
host: HostApi host: HostApi
workspace: WorkspaceApi workspace: WorkspaceApi
commands: CommandsApi
skills: SkillsApi skills: SkillsApi
agentPresets: AgentPresetsApi agentPresets: AgentPresetsApi
events: EventsApi events: EventsApi
@@ -52,7 +50,6 @@ export type {
} from './subagents.ts' } from './subagents.ts'
export type { TaskView } from './tasks.ts' export type { TaskView } from './tasks.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.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 { SkillsApi, SkillEntry } from './skills.ts'
export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'

View File

@@ -7,7 +7,6 @@
import type { SessionsApi } from './sessions.ts' import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts' import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts' import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { AgentPresetsApi } from './agent-presets.ts' import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts' import type { SkillsApi } from './skills.ts'
import type { GoalsApi } from './goals.ts' import type { GoalsApi } from './goals.ts'
@@ -50,8 +49,6 @@ export interface RpcMethodMap {
'workspace.delete': WorkspaceApi['delete'] 'workspace.delete': WorkspaceApi['delete']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
'workspace.archiveSession': WorkspaceApi['archiveSession'] 'workspace.archiveSession': WorkspaceApi['archiveSession']
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']
'skill.list': SkillsApi['list'] 'skill.list': SkillsApi['list']
'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.list': AgentPresetsApi['list']
'agentPreset.select': AgentPresetsApi['select'] 'agentPreset.select': AgentPresetsApi['select']

View File

@@ -39,7 +39,6 @@ import {
workspaceListValueSchema, workspaceListValueSchema,
workspaceRenameValueSchema, workspaceRenameValueSchema,
} from '../api/workspace.schema.ts' } from '../api/workspace.schema.ts'
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts'
import { import {
agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
@@ -120,10 +119,6 @@ export interface IApiClient {
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>> 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: { skills: {
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>> list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
} }
@@ -200,8 +195,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'workspace.delete': workspaceDeleteValueSchema, 'workspace.delete': workspaceDeleteValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema, 'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'workspace.archiveSession': workspaceArchiveSessionValueSchema, 'workspace.archiveSession': workspaceArchiveSessionValueSchema,
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
'skill.list': skillListValueSchema, 'skill.list': skillListValueSchema,
'agentPreset.list': agentPresetListValueSchema, 'agentPreset.list': agentPresetListValueSchema,
'agentPreset.select': agentPresetSelectValueSchema, 'agentPreset.select': agentPresetSelectValueSchema,
@@ -456,15 +449,6 @@ export abstract class AbstractApiClient implements IApiClient {
archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), 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'] = { readonly skills: IApiClient['skills'] = {
list: (payload, signal) => this.callUnary('skill.list', payload, signal), list: (payload, signal) => this.callUnary('skill.list', payload, signal),
} }

View File

@@ -42,7 +42,6 @@ import {
workspaceListRequestSchema, workspaceListRequestSchema,
workspaceRenameRequestSchema, workspaceRenameRequestSchema,
} from '../api/workspace.schema.ts' } from '../api/workspace.schema.ts'
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts'
import { import {
agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
@@ -115,8 +114,6 @@ const UNARY_ROUTES: UnaryRoutes = {
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(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) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },

View File

@@ -76,7 +76,6 @@ export class ApiProxyService extends Service implements ApiProxy {
readonly subagents: ApiProxy['subagents'] readonly subagents: ApiProxy['subagents']
readonly workspace: ApiProxy['workspace'] readonly workspace: ApiProxy['workspace']
readonly host: ApiProxy['host'] readonly host: ApiProxy['host']
readonly commands: ApiProxy['commands']
readonly goals: ApiProxy['goals'] readonly goals: ApiProxy['goals']
readonly skills: ApiProxy['skills'] readonly skills: ApiProxy['skills']
readonly agentPresets: ApiProxy['agentPresets'] readonly agentPresets: ApiProxy['agentPresets']
@@ -102,7 +101,6 @@ export class ApiProxyService extends Service implements ApiProxy {
this.subagents = api.subagents this.subagents = api.subagents
this.workspace = api.workspace this.workspace = api.workspace
this.host = api.host this.host = api.host
this.commands = api.commands
this.goals = api.goals this.goals = api.goals
this.skills = api.skills this.skills = api.skills
this.agentPresets = api.agentPresets this.agentPresets = api.agentPresets

View File

@@ -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 },
],
},
])
})
})

View File

@@ -21,7 +21,6 @@ function scriptedApi(overrides: {
sessions?: Partial<ApiProxy['sessions']> sessions?: Partial<ApiProxy['sessions']>
subagents?: Partial<ApiProxy['subagents']> subagents?: Partial<ApiProxy['subagents']>
host?: Partial<ApiProxy['host']> host?: Partial<ApiProxy['host']>
commands?: Partial<ApiProxy['commands']>
skills?: Partial<ApiProxy['skills']> skills?: Partial<ApiProxy['skills']>
agentPresets?: Partial<ApiProxy['agentPresets']> agentPresets?: Partial<ApiProxy['agentPresets']>
events?: Partial<ApiProxy['events']> events?: Partial<ApiProxy['events']>
@@ -89,11 +88,6 @@ function scriptedApi(overrides: {
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), 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] }), 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 }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
agentPresets: { agentPresets: {
list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), list: r => ok(r, { presets: [], authorable: false, hasDocument: false }),

View File

@@ -1,4 +1,3 @@
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts'
import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts'
@@ -190,25 +189,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } 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: { agentPresets: {
list(request: RpcRequest<{}>) { list(request: RpcRequest<{}>) {
return Promise.resolve({ return Promise.resolve({
@@ -441,19 +421,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(response.result).toEqual({ ok: true, value: { opened: true } }) 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 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 }) 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 }] } }) 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() vi.useFakeTimers()
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => {
const controller = new AbortController() const controller = new AbortController()
@@ -464,16 +438,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
}) })
try { try {
const api = fakeApi() const api = fakeApi()
api.commands.execute = async (request) => { api.host.pickDirectory = async (request) => {
await new Promise(resolve => setTimeout(resolve, 30_001)) await new Promise(resolve => setTimeout(resolve, 30_001))
return { return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } }
rpcId: request.rpcId,
result: { ok: true, value: { matched: true, commandId: CommandId('cmd-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({ 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([ await Promise.all([
@@ -509,10 +480,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
})).result).toEqual({ ok: true, value: { accepted: true } }) })).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 api = fakeApi()
const started = Promise.withResolvers<AbortSignal>() const started = Promise.withResolvers<AbortSignal>()
api.commands.execute = async (request, signal) => { api.host.pickDirectory = async (request, signal) => {
started.resolve(signal) started.resolve(signal)
if (!signal.aborted) { if (!signal.aborted) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@@ -525,10 +496,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
} }
} }
const controller = new AbortController() const controller = new AbortController()
const execution = client(api).commands.execute( const execution = client(api).host.pickDirectory({}, controller.signal)
{ sessionId: 's' as never, line: '/hang' },
controller.signal,
)
const handlerSignal = await started.promise const handlerSignal = await started.promise
controller.abort(new Error('connection closed')) controller.abort(new Error('connection closed'))

View File

@@ -30,6 +30,14 @@
"types": "./lib/types/brand.d.ts", "types": "./lib/types/brand.d.ts",
"default": "./lib/types/brand.js" "default": "./lib/types/brand.js"
}, },
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
"./src/*": "./src/*", "./src/*": "./src/*",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
@@ -37,7 +45,12 @@
"lib/index.js", "lib/index.js",
"lib/invariant.js", "lib/invariant.js",
"lib/types/**/*.js", "lib/types/**/*.js",
"lib/types/**/*.d.ts" "lib/types/**/*.d.ts",
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts",
"lib/typert.remote-client.d.ts.map"
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peerDependencies": { "peerDependencies": {
@@ -46,6 +59,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^" "@deepseek-ai/cordis": "workspace:^"
}, },
"devDependencies": { "devDependencies": {
@@ -54,6 +68,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^" "@deepseek-ai/cordis": "workspace:^"
} }
} }

View File

@@ -3,26 +3,27 @@
* @module @deepseek-ai/dsh-commands * @module @deepseek-ai/dsh-commands
*/ */
import { Context, Service } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
import { CommandId } from './brand.ts' import { CommandId } from './brand.ts'
import type {
CommandDescriptor,
CommandExecution,
CommandInputDescriptor,
CommandResult,
} from './types.ts'
export { CommandId } from './brand.ts' export { CommandId } from './brand.ts'
export type { CommandSource, CommandSourceMap } from './types.ts' export type * from './types.ts'
export const name = 'commands' export const name = 'commands'
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
/** Immutable metadata for a command's optional unstructured input. */
export interface CommandInputDescriptor {
/** Placeholder shown before the user supplies free-form input. */
readonly hint: string
}
/** Invocation passed to one registered command handler. */ /** Invocation passed to one registered command handler. */
export interface CommandInvocation { export interface CommandInvocation {
/** Pairing id already written to this invocation's `command/run` event. */ /** Pairing id already written to this invocation's `command/run` event. */
@@ -35,29 +36,6 @@ export interface CommandInvocation {
readonly signal: AbortSignal readonly signal: AbortSignal
} }
/** Expected command outcome rendered directly by the dispatching UI. */
export type CommandResult =
| {
readonly kind: 'success'
readonly text?: string
/** Earlier authoritative domain event that owns a richer presentation. */
readonly sourceEventSeq?: number
}
| { readonly kind: 'error'; readonly text: string }
/**
* One settled command execution: the handler's normalized result plus the
* lifecycle pairing id minted for its `command/run`/`command/done` records,
* so a dispatching surface can correlate the RPC-level acknowledgment with
* the flow node those events produce.
*/
export interface CommandExecution {
/** Pairing id carried by this execution's lifecycle events. */
readonly commandId: CommandId
/** The handler's normalized outcome. */
readonly result: CommandResult
}
/** Plugin-owned command registration. */ /** Plugin-owned command registration. */
export interface CommandDefinition { export interface CommandDefinition {
/** Lowercase command name without the leading slash. */ /** Lowercase command name without the leading slash. */
@@ -76,16 +54,6 @@ export interface CommandDefinition {
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult> readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
} }
/** Handler-free immutable command view returned to UI adapters. */
export interface CommandDescriptor {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
}
/** Syntactically valid slash command before registry resolution. */ /** Syntactically valid slash command before registry resolution. */
export interface ParsedCommand { export interface ParsedCommand {
/** Lowercase command name without the leading slash. */ /** Lowercase command name without the leading slash. */
@@ -254,7 +222,7 @@ function normalizeResult(command: string, value: unknown): CommandResult {
* registered through a command-injected child of an agent context shadow * registered through a command-injected child of an agent context shadow
* globals for that agent. * globals for that agent.
*/ */
export class CommandService extends Service { export class CommandService extends GatewayService {
private readonly layers = new ScopedLayers( private readonly layers = new ScopedLayers(
scope => new CommandLayer(scope), scope => new CommandLayer(scope),
() => { this.notifyChange() }, () => { this.notifyChange() },
@@ -288,6 +256,7 @@ export class CommandService extends Service {
* @param agent - exact receiving agent and scoped-layer key. * @param agent - exact receiving agent and scoped-layer key.
* @returns name-sorted descriptors after scoped shadowing. * @returns name-sorted descriptors after scoped shadowing.
*/ */
@Remote
list(agent: Agent): readonly CommandDescriptor[] { list(agent: Agent): readonly CommandDescriptor[] {
return Object.freeze([...this.view(agent).values()] return Object.freeze([...this.view(agent).values()]
.map(command => command.descriptor) .map(command => command.descriptor)
@@ -324,6 +293,7 @@ export class CommandService extends Service {
* @returns the settled execution (result + lifecycle pairing id), or * @returns the settled execution (result + lifecycle pairing id), or
* `undefined` when syntax or name does not resolve. * `undefined` when syntax or name does not resolve.
*/ */
@Remote
async execute( async execute(
agent: Agent, agent: Agent,
line: string, line: string,

View File

@@ -9,6 +9,45 @@
import type { CommandId } from './brand.ts' import type { CommandId } from './brand.ts'
/** Immutable metadata for a command's optional unstructured input. */
export interface CommandInputDescriptor {
/** Placeholder shown before the user supplies free-form input. */
readonly hint: string
}
/** Expected command outcome rendered directly by the dispatching UI. */
export type CommandResult =
| {
readonly kind: 'success'
readonly text?: string
/** Earlier authoritative domain event that owns a richer presentation. */
readonly sourceEventSeq?: number
}
| { readonly kind: 'error'; readonly text: string }
/**
* One settled command execution: the handler's normalized result plus the
* lifecycle pairing id minted for its `command/run`/`command/done` records,
* so a dispatching surface can correlate the Remote acknowledgment with the
* flow node those events produce.
*/
export interface CommandExecution {
/** Pairing id carried by this execution's lifecycle events. */
readonly commandId: CommandId
/** The handler's normalized outcome. */
readonly result: CommandResult
}
/** Handler-free immutable command view returned to UI adapters. */
export interface CommandDescriptor {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
}
/** /**
* Producer record for one command invocation (the `command/run` event's * Producer record for one command invocation (the `command/run` event's
* source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s

View File

@@ -28,6 +28,9 @@
}, },
{ {
"path": "../../support/invariants" "path": "../../support/invariants"
},
{
"path": "../../typert/type-meta"
} }
] ]
} }

153
pnpm-lock.yaml generated
View File

@@ -1566,6 +1566,12 @@ importers:
'@deepseek-ai/dsh-client-ui-deliverables': '@deepseek-ai/dsh-client-ui-deliverables':
specifier: workspace:^ specifier: workspace:^
version: link:../../client/ui-deliverables version: link:../../client/ui-deliverables
'@deepseek-ai/dsh-client-ui-directory-picker':
specifier: workspace:^
version: link:../../client/ui-directory-picker
'@deepseek-ai/dsh-client-ui-directory-picker-native':
specifier: workspace:^
version: link:../../client/ui-directory-picker-native
'@deepseek-ai/dsh-client-ui-goal': '@deepseek-ai/dsh-client-ui-goal':
specifier: workspace:^ specifier: workspace:^
version: link:../../client/ui-goal version: link:../../client/ui-goal
@@ -1862,9 +1868,9 @@ importers:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-gateway': '@deepseek-ai/dsh-api-remotes':
specifier: workspace:^ specifier: workspace:^
version: link:../../api/gateway version: link:../../api/remotes
'@deepseek-ai/dsh-invariants': '@deepseek-ai/dsh-invariants':
specifier: workspace:^ specifier: workspace:^
version: link:../../support/invariants version: link:../../support/invariants
@@ -2049,6 +2055,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots': '@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^ specifier: workspace:^
version: link:../ui-slots version: link:../ui-slots
'@deepseek-ai/dsh-commands':
specifier: workspace:^
version: link:../../interaction/commands
'@deepseek-ai/dsh-invariants': '@deepseek-ai/dsh-invariants':
specifier: workspace:^ specifier: workspace:^
version: link:../../support/invariants version: link:../../support/invariants
@@ -2190,6 +2199,82 @@ importers:
specifier: ~18.3.1 specifier: ~18.3.1
version: 18.3.31 version: 18.3.31
packages/client/ui-directory-picker:
dependencies:
clsx:
specifier: ^2.0.0
version: 2.1.1
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^
version: link:../test-runtime
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-client-ui-workspace':
specifier: workspace:^
version: link:../ui-workspace
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@testing-library/react':
specifier: ^16.1.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages/client/ui-directory-picker-native:
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^
version: link:../test-runtime
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-client-ui-workspace':
specifier: workspace:^
version: link:../ui-workspace
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@testing-library/react':
specifier: ^16.1.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages/client/ui-goal: packages/client/ui-goal:
devDependencies: devDependencies:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
@@ -2417,9 +2502,9 @@ importers:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-client-connection': '@deepseek-ai/dsh-api-remotes':
specifier: workspace:^ specifier: workspace:^
version: link:../connection version: link:../../api/remotes
'@deepseek-ai/dsh-client-locale': '@deepseek-ai/dsh-client-locale':
specifier: workspace:^ specifier: workspace:^
version: link:../locale version: link:../locale
@@ -2606,6 +2691,9 @@ importers:
'@deepseek-ai/dsh-agent': '@deepseek-ai/dsh-agent':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/agent version: link:../../core/agent
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-client-locale': '@deepseek-ai/dsh-client-locale':
specifier: workspace:^ specifier: workspace:^
version: link:../locale version: link:../locale
@@ -2630,9 +2718,6 @@ importers:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-gateway':
specifier: workspace:^
version: link:../../api/gateway
'@deepseek-ai/dsh-api-remotes': '@deepseek-ai/dsh-api-remotes':
specifier: workspace:^ specifier: workspace:^
version: link:../../api/remotes version: link:../../api/remotes
@@ -2988,6 +3073,9 @@ importers:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-client-connection': '@deepseek-ai/dsh-client-connection':
specifier: workspace:^ specifier: workspace:^
version: link:../connection version: link:../connection
@@ -4777,6 +4865,12 @@ importers:
'@deepseek-ai/cordis-plugin-loader': '@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/loader version: link:../../../vendor/loader
'@deepseek-ai/dsh-client-ui-directory-picker':
specifier: workspace:^
version: link:../../client/ui-directory-picker
'@deepseek-ai/dsh-client-ui-directory-picker-native':
specifier: workspace:^
version: link:../../client/ui-directory-picker-native
'@deepseek-ai/dsh-host-directory-picker': '@deepseek-ai/dsh-host-directory-picker':
specifier: workspace:^ specifier: workspace:^
version: link:../directory-picker version: link:../directory-picker
@@ -4801,40 +4895,13 @@ importers:
'@deepseek-ai/schemastery': '@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery version: link:../../../vendor/schemastery
clsx:
specifier: ^2.0.0
version: 2.1.1
devDependencies: devDependencies:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../../client/locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../../client/runtime
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^
version: link:../../client/test-runtime
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../../client/ui-primitives
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../../client/ui-slots
'@deepseek-ai/dsh-client-ui-workspace':
specifier: workspace:^
version: link:../../client/ui-workspace
'@deepseek-ai/dsh-invariants': '@deepseek-ai/dsh-invariants':
specifier: workspace:^ specifier: workspace:^
version: link:../../support/invariants version: link:../../support/invariants
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
packages/host/directory-picker-native: packages/host/directory-picker-native:
dependencies: dependencies:
@@ -4851,24 +4918,9 @@ importers:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../../client/runtime
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../../client/ui-slots
'@deepseek-ai/dsh-client-ui-workspace':
specifier: workspace:^
version: link:../../client/ui-workspace
'@deepseek-ai/dsh-invariants': '@deepseek-ai/dsh-invariants':
specifier: workspace:^ specifier: workspace:^
version: link:../../support/invariants version: link:../../support/invariants
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
tsx: tsx:
specifier: ^4.19.2 specifier: ^4.19.2
version: 4.22.4 version: 4.22.4
@@ -4925,6 +4977,9 @@ importers:
'@deepseek-ai/dsh-session': '@deepseek-ai/dsh-session':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/session version: link:../../core/session
'@deepseek-ai/dsh-type-meta':
specifier: workspace:^
version: link:../../typert/type-meta
packages/interaction/permission: packages/interaction/permission:
dependencies: dependencies: