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:
@@ -10,7 +10,7 @@ export type {
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
// Type-only: the brand constructor is host-side; the fixture casts at its
|
||||
// wire-fabrication boundary (the schema layer's one-cast-point posture).
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types'
|
||||
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
@@ -1379,11 +1380,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return createFixtureWorld(options).api
|
||||
}
|
||||
|
||||
interface FixtureWorld {
|
||||
/** Both fixture faces over one state graph. */
|
||||
export interface FixtureWorld {
|
||||
/** Legacy unary/stream API the fixture still answers. */
|
||||
readonly api: ApiProxy
|
||||
/** Generic Remote caller for the endpoints business services own. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
}
|
||||
|
||||
/**
|
||||
* Build both fixture faces so a caller can drive the Remote endpoints and the
|
||||
* legacy API against one in-memory state graph.
|
||||
* @param options - fixture branches for empty state and failure timing.
|
||||
* @returns the legacy API face and the Remote RPC face.
|
||||
*/
|
||||
export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld {
|
||||
return createFixtureWorld(options)
|
||||
}
|
||||
|
||||
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
||||
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
@@ -1598,6 +1612,98 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
: undefined
|
||||
)
|
||||
|
||||
/** Canonical fixture implementation of the generated Commands Remote contract. */
|
||||
const commandRemotes = {
|
||||
list(id: SessionId): RpcResult<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 => ({
|
||||
...projection.goal,
|
||||
roundsStarted: projection.roundsStarted,
|
||||
@@ -2446,104 +2552,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return ok(request, { archivedSessionIds: [...archivedSessionIds] })
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
// The catalog mirrors one session's effective view (every fixture
|
||||
// session has an agent, like the real host).
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
commands: [
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<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: {
|
||||
// Both trusts appear, because a surface must present a locally authored
|
||||
// preset differently from one the deployment vetted.
|
||||
@@ -2852,12 +2860,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
const args = (payload as {
|
||||
args: {
|
||||
agentId: SessionId
|
||||
line?: string
|
||||
ref?: { id: string; revision: number }
|
||||
request?: { objective?: string; maxGoalRounds?: number }
|
||||
}
|
||||
}).args
|
||||
const sessionId = args.agentId
|
||||
switch (endpoint) {
|
||||
case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId))
|
||||
case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string))
|
||||
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
|
||||
objective: args.request?.objective as string,
|
||||
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
|
||||
@@ -2950,8 +2961,6 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.delete': return this.api.workspace.delete(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
case 'workspace.archiveSession': return this.api.workspace.archiveSession(request)
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
case 'command.execute': return this.api.commands.execute(request, signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'agentPreset.list': return this.api.agentPresets.list(request)
|
||||
case 'agentPreset.select': return this.api.agentPresets.select(request)
|
||||
|
||||
@@ -18,7 +18,7 @@ export type {
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
@@ -169,19 +168,10 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program catalogs and skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<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[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
/**
|
||||
* Fixture commands/skills domains: contract-shape conformance for the two
|
||||
* domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute
|
||||
* parse/dispatch, skill.list session resolution, and the FixtureApiClient
|
||||
* dispatch rows.
|
||||
* Fixture commands/skills domains: session-addressed catalogs, execute
|
||||
* parse/dispatch and its logged lifecycle pair, skill.list session resolution,
|
||||
* and the FixtureApiClient dispatch rows. Commands answer on the Remote face
|
||||
* and skills on the legacy API face, so both are driven here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import type { RpcRequest } from '../src/client/api.ts'
|
||||
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
||||
import { FixtureApiClient, createFixtureApi, createFixtureFaces } from '../src/client/fixture.ts'
|
||||
|
||||
/** Drive one commands Remote endpoint against the fixture state graph. */
|
||||
async function callRemote<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
|
||||
let reqCount = 0
|
||||
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
|
||||
const signal = new AbortController().signal
|
||||
|
||||
describe('createFixtureApi commands/skills', () => {
|
||||
it('serves the addressed session catalog with rpcId echo', async () => {
|
||||
const api = createFixtureApi()
|
||||
const request = req({ sessionId: sid('fx-alpha') })
|
||||
const response = await api.commands.list(request)
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
const commands = response.result.value.commands
|
||||
it('serves the addressed session catalog', async () => {
|
||||
const { rpc } = createFixtureFaces()
|
||||
const commands = await callRemote<{ name: string; input?: { hint: string } }[]>(
|
||||
rpc, 'commands/list', { agentId: sid('fx-alpha') })
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
@@ -31,13 +38,13 @@ describe('createFixtureApi commands/skills', () => {
|
||||
})
|
||||
|
||||
it('rejects a catalog request for an unknown session', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.commands.list(req({ sessionId: sid('fx-nope') }))
|
||||
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
const { rpc } = createFixtureFaces()
|
||||
const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
|
||||
const api = createFixtureApi()
|
||||
const { api, rpc } = createFixtureFaces()
|
||||
const frames: unknown[] = []
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux(req({}), abort.signal)
|
||||
@@ -47,10 +54,9 @@ describe('createFixtureApi commands/skills', () => {
|
||||
if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value).toMatchObject({ matched: true })
|
||||
expect(response.result.value.commandId).toBeTruthy()
|
||||
const execution = await callRemote<{ commandId: string } | undefined>(
|
||||
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hello world' })
|
||||
expect(execution?.commandId).toBeTruthy()
|
||||
await pump
|
||||
const events = frames
|
||||
.filter((f): f is { type: string; event: { type: string; data: Record<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 () => {
|
||||
const api = createFixtureApi()
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal)
|
||||
if (!hit.result.ok) throw new Error('execute failed')
|
||||
expect(hit.result.value.matched).toBe(true)
|
||||
const { rpc } = createFixtureFaces()
|
||||
const hit = await callRemote<{ commandId: string } | undefined>(
|
||||
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship' })
|
||||
expect(hit?.commandId).toBeTruthy()
|
||||
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal)
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
const missing = await rpc.call('/api', 'commands/execute', {
|
||||
args: { agentId: sid('fx-nope'), line: '/goal ship' },
|
||||
})
|
||||
expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('falls to matched:false on unknown names and non-command lines', async () => {
|
||||
const api = createFixtureApi()
|
||||
it('answers no execution for unknown names and non-command lines', async () => {
|
||||
const { rpc } = createFixtureFaces()
|
||||
for (const line of ['/nope', 'plain text', '/']) {
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
// Pure admission value: the matched bit is the whole response shape.
|
||||
expect(response.result.value).toEqual({ matched: false })
|
||||
// Absence is the whole answer: nothing matched, so no lifecycle id exists.
|
||||
expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line }))
|
||||
.toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -94,14 +101,13 @@ describe('createFixtureApi commands/skills', () => {
|
||||
})
|
||||
|
||||
describe('FixtureApiClient command/skill dispatch', () => {
|
||||
it('routes the three method keys through the in-memory dispatch table', async () => {
|
||||
it('routes the Remote commands face and the legacy skill row through one state graph', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const list = await client.commands.list({ sessionId: sid('fx-alpha') })
|
||||
if (!list.result.ok) throw new Error('command.list failed')
|
||||
expect(list.result.value.commands.length).toBeGreaterThan(0)
|
||||
const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' })
|
||||
if (!executed.result.ok) throw new Error('command.execute failed')
|
||||
expect(executed.result.value.matched).toBe(true)
|
||||
const commands = await callRemote<{ name: string }[]>(client.rpc, 'commands/list', { agentId: sid('fx-alpha') })
|
||||
expect(commands.length).toBeGreaterThan(0)
|
||||
const executed = await callRemote<{ commandId: string } | undefined>(
|
||||
client.rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' })
|
||||
expect(executed?.commandId).toBeTruthy()
|
||||
const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
|
||||
if (!skills.result.ok) throw new Error('skill.list failed')
|
||||
expect(skills.result.value.skills.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
@@ -59,19 +59,19 @@
|
||||
"zustand": "~4.4.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@types/react": "~18.3.1"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import { Context as CordisContext } from '@deepseek-ai/cordis'
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
|
||||
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { ConversationSnapshot } from '../sessions/conversation.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
@@ -75,9 +76,9 @@ export interface ISession {
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
* @returns the admission result, or the Remote face's error branch.
|
||||
*/
|
||||
command(line: string): Promise<RpcResult<{ matched: boolean }>>
|
||||
command(line: string): Promise<RemoteResult<{ matched: boolean }>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* dependency.
|
||||
*/
|
||||
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
RpcResult, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AgentContext } from '../agents/scope.ts'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the concrete class. Widening this interface is the explicit act of
|
||||
* widening what features may do to the workspaces domain.
|
||||
*/
|
||||
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { WorkspaceListState } from '../workspaces/service.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
|
||||
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this
|
||||
// project sits in the Host build graph.
|
||||
import type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
@@ -179,7 +179,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
|
||||
/** Required services: the wire handle and Client TypeRT registry. */
|
||||
export const inject = ['connection', 'typert', 'remote']
|
||||
export const inject = ['connection', 'typert', 'remote', 'remote.commands']
|
||||
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
@@ -191,7 +191,7 @@ export function apply(ctx: Context): void {
|
||||
views: new ConversationViewRegistry(ctx),
|
||||
}
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api, conversation)
|
||||
const sessions = new SessionsService(ctx, connection.api, ctx.remote, conversation)
|
||||
ctx.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => sessions.scopeOf(candidate),
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
|
||||
import type {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// The input order is authoritative; lineage only makes each child adjacent to its parent.
|
||||
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-session-title/client'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import { Session } from './session.ts'
|
||||
import type { SessionRemotes } from './remotes.ts'
|
||||
|
||||
/**
|
||||
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
|
||||
@@ -164,6 +165,7 @@ export class SessionManager {
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
private readonly remote: SessionRemotes,
|
||||
restoredSelection?: SessionId,
|
||||
restoredAddress?: SubagentAddress,
|
||||
private readonly conversation?: ConversationRuntime,
|
||||
@@ -304,7 +306,7 @@ export class SessionManager {
|
||||
|
||||
private createSession(sessionId: SessionId): Session {
|
||||
const address = this.addresses.get(sessionId)
|
||||
return new Session(sessionId, this.api, {
|
||||
return new Session(sessionId, this.api, this.remote, {
|
||||
...(address === undefined ? {} : {
|
||||
address,
|
||||
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type {
|
||||
ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */
|
||||
export interface PendingPayloads {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { MuxFrame } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { QueuedMessage } from './conversation.ts'
|
||||
|
||||
|
||||
12
packages/client/runtime/src/client/sessions/remotes.ts
Normal file
12
packages/client/runtime/src/client/sessions/remotes.ts
Normal 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'>
|
||||
@@ -17,7 +17,7 @@
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -32,6 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import type { ConversationRuntime } from './conversation-assembler.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionRemotes } from './remotes.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
@@ -271,11 +272,13 @@ export class SessionsService implements ISessions {
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
* @param remote - generated Remote namespaces shared with every Session.
|
||||
* @param conversationRuntime - same-pass registry instances, when runtime apply owns them.
|
||||
*/
|
||||
constructor(
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
remote: SessionRemotes,
|
||||
conversationRuntime?: ConversationRuntime,
|
||||
) {
|
||||
this.selection = createSnapshotStore<SessionSelection>(
|
||||
@@ -291,6 +294,7 @@ export class SessionsService implements ISessions {
|
||||
)
|
||||
this.manager = new SessionManager(
|
||||
api,
|
||||
remote,
|
||||
restored.sessionId,
|
||||
restored.subagentAddress,
|
||||
conversation,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
|
||||
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -21,6 +21,8 @@ import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { SessionRemotes } from './remotes.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
import { resolvedClientTimeZone } from '../time-zone.ts'
|
||||
@@ -134,11 +136,13 @@ export class Session implements SessionFace {
|
||||
/**
|
||||
* @param sessionId - Host session identity (client sessions are always Host-born).
|
||||
* @param api - shared wire client.
|
||||
* @param remote - generated Remote namespaces this session calls.
|
||||
* @param options - optional manager-owned state observers.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
private readonly remote: SessionRemotes,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
@@ -351,12 +355,10 @@ export class Session implements SessionFace {
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
async command(line: string): Promise<RpcResult<{ matched: boolean }>> {
|
||||
try {
|
||||
return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
|
||||
const result = await this.remote.commands.execute(this.sessionId, line)
|
||||
if (!result.ok) return result
|
||||
return { ok: true, value: { matched: result.value !== undefined } }
|
||||
}
|
||||
|
||||
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* uninterrupted subagent subtree.
|
||||
* @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage
|
||||
*/
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionSummary } from './service.ts'
|
||||
|
||||
/** Descendant counts projected for one possible parent session. */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type {
|
||||
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
DirectoryListing, IApiClient, RpcError,
|
||||
SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type {
|
||||
IApiClient, RpcResult, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
|
||||
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
|
||||
import type {
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, fakeRemote, ok } from './fake-api.ts'
|
||||
|
||||
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
|
||||
return {
|
||||
@@ -145,7 +145,7 @@ describe('Conversation registries', () => {
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
|
||||
}) as never)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
await sessions.refresh()
|
||||
await Promise.resolve()
|
||||
sessions.scope(sessionId)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
|
||||
|
||||
describe('toAssistantBlock', () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
ClientResponse, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
|
||||
|
||||
/** Programmable-default workspace row (branded id, ISO-ish times). */
|
||||
function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
|
||||
@@ -55,6 +55,20 @@ interface StreamConn<F> {
|
||||
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 {
|
||||
/** Chronological call record: [method, payload]. */
|
||||
readonly calls: { method: string; payload: unknown }[] = []
|
||||
@@ -205,19 +219,10 @@ export class FakeApiClient implements IApiClient {
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program requires-bearing catalogs and dual-address
|
||||
// skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<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[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { flattenLineage } from '../src/client/sessions/lineage.ts'
|
||||
|
||||
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
@@ -28,7 +28,7 @@ describe('instances', () => {
|
||||
it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
const session = manager.get(S1)
|
||||
expect(manager.get(S1)).toBe(session) // resident: same instance forever
|
||||
@@ -37,7 +37,7 @@ describe('instances', () => {
|
||||
|
||||
it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
// Uninstantiated: approval buffers, plain session/event drops.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
@@ -50,7 +50,7 @@ describe('instances', () => {
|
||||
|
||||
it('retains every live answerable request and compacts resolutions before instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
@@ -67,7 +67,7 @@ describe('instances', () => {
|
||||
})
|
||||
|
||||
it('drops buffered answerable requests on session removal', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
// Removed session: buffered frames must not replay on a future instantiation.
|
||||
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
|
||||
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
|
||||
@@ -80,7 +80,7 @@ describe('list lifecycle', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const first = manager.refreshList()
|
||||
const second = manager.refreshList()
|
||||
expect(manager.getListSnapshot().state).toBe('loading')
|
||||
@@ -96,7 +96,7 @@ describe('list lifecycle', () => {
|
||||
const api = new FakeApiClient()
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const hydration = manager.refreshList()
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'during-first' as never,
|
||||
@@ -116,7 +116,7 @@ describe('list lifecycle', () => {
|
||||
it('keeps the error in the list snapshot on failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
|
||||
// A failed pull does not step the arrival phase: still pending.
|
||||
@@ -125,7 +125,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('phase steps pending → ready on the first successful pull and never returns', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
expect(manager.getListSnapshot().phase).toBe('pending')
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot().phase).toBe('ready')
|
||||
@@ -144,7 +144,7 @@ describe('list lifecycle', () => {
|
||||
it('merges create into the list immediately without waiting for a refresh', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const result = await manager.create()
|
||||
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
|
||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||
@@ -152,7 +152,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const titleFrame = (rpcId: string, title: string, seq: number) => {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: rpcId as never,
|
||||
@@ -179,7 +179,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
// A push frame landed before the list (S2's title is newer than the block's cut).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'push-newer' as never,
|
||||
@@ -202,7 +202,7 @@ describe('list lifecycle', () => {
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
const frame = (rpcId: string, payload: object) => {
|
||||
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
|
||||
@@ -230,7 +230,7 @@ describe('search', () => {
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
|
||||
@@ -246,7 +246,7 @@ describe('search', () => {
|
||||
|
||||
it('preserves business errors and folds transport failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
api.onSearch = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'index unavailable',
|
||||
@@ -269,7 +269,7 @@ describe('search', () => {
|
||||
describe('host frame routing', () => {
|
||||
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
@@ -303,7 +303,7 @@ describe('subagent catalogs', () => {
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
|
||||
@@ -368,7 +368,7 @@ describe('subagent catalogs', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.setSubagentCatalogOpen(S1, true)
|
||||
await Promise.resolve()
|
||||
@@ -418,7 +418,7 @@ describe('subagent catalogs', () => {
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
@@ -447,7 +447,7 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
@@ -488,7 +488,7 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
@@ -529,7 +529,7 @@ describe('subagent catalogs', () => {
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshSubagents(S1)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
@@ -547,7 +547,7 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
expect(manager.refreshSubagents(root)).toBe(refresh)
|
||||
@@ -566,7 +566,7 @@ describe('subagent catalogs', () => {
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api, root)
|
||||
const manager = new SessionManager(api, fakeRemote(), root)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
// A membership frame arrives while the pull is in flight; the debounced
|
||||
@@ -624,7 +624,7 @@ describe('subagent catalogs', () => {
|
||||
})
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await refresh
|
||||
@@ -671,7 +671,7 @@ describe('subagent catalogs', () => {
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshSubagents(root)
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
|
||||
@@ -690,14 +690,14 @@ describe('remaining branches', () => {
|
||||
it('refreshList folds a transport throw into the error state', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.reject(new Error('list wire down'))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
|
||||
})
|
||||
|
||||
it('refreshList pushes running bits down to already-instantiated sessions', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const session = manager.get(S1)
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
|
||||
await manager.refreshList()
|
||||
@@ -707,7 +707,7 @@ describe('remaining branches', () => {
|
||||
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
|
||||
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
|
||||
@@ -727,7 +727,7 @@ describe('remaining branches', () => {
|
||||
message: 'published but unattached',
|
||||
details: { sessionId: S1, workspaceId: 'w1' },
|
||||
} as never))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
|
||||
@@ -741,7 +741,7 @@ describe('remaining branches', () => {
|
||||
message: 'forked but unattached',
|
||||
details: { sessionId: S2, workspaceId: 'w1' },
|
||||
} as never))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const result = await manager.fork({ sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
|
||||
@@ -754,7 +754,7 @@ describe('remaining branches', () => {
|
||||
it('reconciles a preallocated id after an ordinary transport failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.reject(new Error('response lost'))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
|
||||
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([])
|
||||
@@ -775,7 +775,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
let notified = 0
|
||||
const unsubscribe = manager.subscribe(() => { notified++ })
|
||||
await manager.refreshList()
|
||||
@@ -790,7 +790,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
|
||||
manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
|
||||
manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never })
|
||||
@@ -805,7 +805,7 @@ describe('remaining branches', () => {
|
||||
it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
const before = manager.getListSnapshot()
|
||||
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
|
||||
@@ -821,7 +821,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('carries parentSessionId from host/session-added into the lineage row', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'h2' as never,
|
||||
@@ -845,7 +845,7 @@ describe('connected generation', () => {
|
||||
hasMore: false,
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const openedSession = manager.get(S1)
|
||||
await openedSession.open()
|
||||
manager.get(S2) // instantiated but never opened
|
||||
@@ -863,7 +863,7 @@ describe('connected generation', () => {
|
||||
const address = {
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
|
||||
}
|
||||
const manager = new SessionManager(api, S2, address)
|
||||
const manager = new SessionManager(api, fakeRemote(), S2, address)
|
||||
|
||||
manager.handleConnected()
|
||||
|
||||
@@ -876,7 +876,7 @@ describe('connected generation', () => {
|
||||
|
||||
describe('pending-interaction list status', () => {
|
||||
it('tracks approval requests through replay and resolution without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
@@ -889,7 +889,7 @@ describe('pending-interaction list status', () => {
|
||||
})
|
||||
|
||||
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
@@ -922,7 +922,7 @@ describe('pending-interaction list status', () => {
|
||||
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
|
||||
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
|
||||
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q-plan' as never,
|
||||
@@ -939,7 +939,7 @@ describe('pending-interaction list status', () => {
|
||||
})
|
||||
|
||||
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({
|
||||
@@ -958,7 +958,7 @@ describe('pending-interaction list status', () => {
|
||||
})
|
||||
|
||||
it('drops stale status at generation death before replay re-adds live interactions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
@@ -973,7 +973,7 @@ describe('pending-interaction list status', () => {
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
// Buffered pre-instantiation: an approval pair and a queued row.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
@@ -1000,7 +1000,7 @@ describe('completed reminder', () => {
|
||||
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
|
||||
|
||||
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
@@ -1014,7 +1014,7 @@ describe('completed reminder', () => {
|
||||
})
|
||||
|
||||
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S2)
|
||||
@@ -1029,7 +1029,7 @@ describe('completed reminder', () => {
|
||||
})
|
||||
|
||||
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
@@ -1044,7 +1044,7 @@ describe('completed reminder', () => {
|
||||
})
|
||||
|
||||
it('session-removed drops the reminder and a re-add starts clean', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
@@ -1060,7 +1060,7 @@ describe('completed reminder', () => {
|
||||
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
@@ -1072,7 +1072,7 @@ describe('completed reminder', () => {
|
||||
it('never arms for sessions already idle at first observation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
@@ -1085,7 +1085,7 @@ describe('completed reminder', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshList()
|
||||
// The session finishes while the first pull is still in flight; the pull
|
||||
// response recorded it as running at pull time.
|
||||
@@ -1099,7 +1099,7 @@ describe('completed reminder', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
const refresh = manager.refreshList()
|
||||
// The unknown session starts and finishes while the first pull is in
|
||||
// flight; the pull-time baseline recorded it idle, so the running→idle
|
||||
@@ -1120,7 +1120,7 @@ describe('background-task mirror', () => {
|
||||
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
|
||||
|
||||
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
|
||||
const first = manager.getListSnapshot().tasksBySession
|
||||
@@ -1133,7 +1133,7 @@ describe('background-task mirror', () => {
|
||||
})
|
||||
|
||||
it('stores an emptied set as an absent key so absence and [] read alike', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, []))
|
||||
@@ -1141,7 +1141,7 @@ describe('background-task mirror', () => {
|
||||
})
|
||||
|
||||
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 's' as never,
|
||||
@@ -1151,7 +1151,7 @@ describe('background-task mirror', () => {
|
||||
})
|
||||
|
||||
it('drops the rows when the session is removed, whichever stream lands first', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
@@ -1159,7 +1159,7 @@ describe('background-task mirror', () => {
|
||||
})
|
||||
|
||||
it('notifies list subscribers so an open header re-renders without a poll', async () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
const seen = vi.fn()
|
||||
manager.subscribe(seen)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { PartialAccumulator } from '../src/client/sessions/partial.ts'
|
||||
|
||||
const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* list rows' title projection).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, fakeRemote, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
|
||||
// Test-domain keys merged into the projection map (the Service Definition package's
|
||||
@@ -103,7 +103,7 @@ describe('ProjectionValueStore semantics', () => {
|
||||
describe('Session tail-page seeding', () => {
|
||||
it('seeds the store from a history response carrying a projections block', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
const session = new Session(SID, api, fakeRemote())
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
|
||||
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
|
||||
@@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => {
|
||||
|
||||
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
const session = new Session(SID, api, fakeRemote())
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
||||
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
|
||||
@@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => {
|
||||
|
||||
it('treats a blockless response as no reset: pushed values survive', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
const session = new Session(SID, api, fakeRemote())
|
||||
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
await session.open()
|
||||
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
|
||||
@@ -141,7 +141,7 @@ describe('manager frame routing', () => {
|
||||
|
||||
it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
|
||||
@@ -158,7 +158,7 @@ describe('manager frame routing', () => {
|
||||
|
||||
it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
@@ -181,7 +181,7 @@ describe('manager frame routing', () => {
|
||||
|
||||
it('projects every retained value into list rows with stable snapshot identity', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{
|
||||
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
|
||||
@@ -211,7 +211,7 @@ describe('manager frame routing', () => {
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
|
||||
@@ -7,10 +7,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
import { FakeApiClient, fakeRemote } from './fake-api.ts'
|
||||
|
||||
const SID = 'fk-q1' as SessionId
|
||||
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
|
||||
@@ -42,7 +42,7 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
|
||||
}
|
||||
|
||||
function makeSession(): Session {
|
||||
return new Session(SID, new FakeApiClient())
|
||||
return new Session(SID, new FakeApiClient(), fakeRemote())
|
||||
}
|
||||
|
||||
describe('queue snapshot intake', () => {
|
||||
@@ -198,7 +198,7 @@ describe('queue snapshot intake', () => {
|
||||
describe('queue operation transport', () => {
|
||||
it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
const session = new Session(SID, api, fakeRemote())
|
||||
session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
|
||||
const before = session.getSnapshot().queue
|
||||
|
||||
@@ -251,14 +251,14 @@ describe('queue reconnect semantics', () => {
|
||||
|
||||
describe('manager buffering of queue snapshots', () => {
|
||||
it('replays only the latest snapshot for an uninstantiated session', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
|
||||
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
|
||||
expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
|
||||
})
|
||||
|
||||
it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const manager = new SessionManager(new FakeApiClient(), fakeRemote())
|
||||
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: rid('g1b'),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot,
|
||||
ConversationViewDefinition,
|
||||
} from '../src/client/index.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
@@ -159,7 +159,7 @@ const TEST_CONVERSATION: ConversationRuntime = {
|
||||
}
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) }
|
||||
return { api, session: new Session(SID, api, fakeRemote(), { conversation: TEST_CONVERSATION }) }
|
||||
}
|
||||
|
||||
function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] {
|
||||
@@ -343,7 +343,7 @@ describe('live event path', () => {
|
||||
entries: () => [testViewDefinition()],
|
||||
} as unknown as ConversationRuntime['views'],
|
||||
}
|
||||
const session = new Session(SID, api, { conversation })
|
||||
const session = new Session(SID, api, fakeRemote(), { conversation })
|
||||
await session.open()
|
||||
const snapshots: ConversationSnapshot[] = []
|
||||
session.subscribe(() => { snapshots.push(session.getSnapshot()) })
|
||||
@@ -448,7 +448,7 @@ describe('paging', () => {
|
||||
describe('prompt and cancel errors', () => {
|
||||
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
const session = new Session(SID, api, fakeRemote(), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
@@ -487,7 +487,7 @@ describe('prompt and cancel errors', () => {
|
||||
api.onSubagentInterrupt = () => Promise.resolve(err({
|
||||
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
|
||||
}) as never)
|
||||
const session = new Session(SID, api, {
|
||||
const session = new Session(SID, api, fakeRemote(), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
@@ -501,7 +501,7 @@ describe('prompt and cancel errors', () => {
|
||||
|
||||
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
const session = new Session(SID, api, fakeRemote(), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
|
||||
})
|
||||
await session.open()
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Bench {
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const svc = new SessionsService(ctx, api)
|
||||
const svc = new SessionsService(ctx, api, fakeRemote())
|
||||
return { ctx, api, svc }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
// Type-only: the api-remotes facade carries both the allowlist's selection seat
|
||||
// and the owner packages' `./types` declarations, which together give `$on` its
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
const wid = (id: string): WorkspaceId => id as WorkspaceId
|
||||
@@ -124,7 +124,7 @@ describe('WorkspacesService', () => {
|
||||
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
@@ -152,7 +152,7 @@ describe('WorkspacesService', () => {
|
||||
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
|
||||
@@ -211,7 +211,7 @@ describe('WorkspacesService', () => {
|
||||
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
@@ -231,7 +231,7 @@ describe('WorkspacesService', () => {
|
||||
it('returns created Workspaces and preserves Host business errors', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceCreate = () => Promise.resolve(ok({
|
||||
workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
|
||||
@@ -250,7 +250,7 @@ describe('WorkspacesService', () => {
|
||||
it('passes native directory selection and cancellation through without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
|
||||
await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
|
||||
@@ -264,7 +264,7 @@ describe('WorkspacesService', () => {
|
||||
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote()))
|
||||
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
|
||||
api.onListDirectory = () => Promise.resolve(ok(listing))
|
||||
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
|
||||
@@ -285,7 +285,7 @@ describe('WorkspacesService', () => {
|
||||
it('opens a filesystem path through the host without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
|
||||
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
|
||||
@@ -296,7 +296,7 @@ describe('WorkspacesService', () => {
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
await workspaces.refresh()
|
||||
@@ -312,7 +312,7 @@ describe('WorkspacesService', () => {
|
||||
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
@@ -358,7 +358,7 @@ describe('WorkspacesService', () => {
|
||||
it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
|
||||
@@ -392,7 +392,7 @@ describe('startInitialSelection', () => {
|
||||
function bench() {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
return { api, sessions, workspaces }
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
@@ -60,7 +57,7 @@
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../api/gateway"
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -50,16 +50,16 @@
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -72,6 +72,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
|
||||
@@ -5,13 +5,10 @@
|
||||
* / epoch-guard behavior of the original global cache; the session-key axis
|
||||
* is the only extra dimension.
|
||||
*/
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** command.list success value, derived so the wire type authority stays in apiproxy. */
|
||||
type ListValue = Extract<Awaited<ReturnType<IApiClient['commands']['list']>>['result'], { ok: true }>['value']
|
||||
|
||||
/** One host command descriptor as served to the client. */
|
||||
export type CommandDescriptor = ListValue['commands'][number]
|
||||
export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
|
||||
|
||||
/**
|
||||
* cold = never pulled; pending = pull in flight with nothing servable;
|
||||
|
||||
@@ -44,8 +44,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'command'
|
||||
|
||||
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
|
||||
export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote']
|
||||
/** Required services: the '/' source registry, session scopes, commands Remote, and locale registry. */
|
||||
export const inject = ['slash', 'sessions', 'remote', 'remote.commands', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount the service, then register the popupSelect shell
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
*/
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (`commands/change` rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
SubmitOutcome,
|
||||
@@ -96,7 +95,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string
|
||||
|
||||
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
|
||||
export class CommandService extends Service implements CommandServiceContract {
|
||||
static inject = ['slash', 'sessions', 'connection', 'remote']
|
||||
static inject = ['slash', 'sessions', 'remote', 'remote.commands']
|
||||
|
||||
private readonly directory: CommandDirectory
|
||||
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
|
||||
@@ -107,13 +106,11 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'command')
|
||||
const connection = ctx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
|
||||
this.directory = new CommandDirectory(async (sessionId) => {
|
||||
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
|
||||
const { result } = await connection.api.commands.list({ sessionId })
|
||||
const result = await ctx.remote.commands.list(sessionId)
|
||||
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.commands
|
||||
return result.value
|
||||
})
|
||||
const slash = ctx.get('slash')
|
||||
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
|
||||
@@ -351,10 +348,9 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
session: ClientSessionContext,
|
||||
line: string,
|
||||
): Promise<SubmitOutcome> {
|
||||
const connection = this.ctx.get('connection') as ConnectionHandle
|
||||
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
|
||||
const result = await this.ctx.remote.commands.execute(session.sessionId, line)
|
||||
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||||
if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||||
return { kind: 'success' }
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandServiceContract } from '../src/client/contract.ts'
|
||||
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, CommandService, inject } from '../src/client/index.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
@@ -33,14 +32,14 @@ async function bench() {
|
||||
scope: (id: SessionId) => scopes.get(id),
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } })
|
||||
const commandsRemote = { list: () => Promise.resolve([]) }
|
||||
ctx.provide('remote', { commands: commandsRemote })
|
||||
ctx.provide('remote.commands', commandsRemote)
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
// CommandService injects `remote` for the forwarded directory invalidation.
|
||||
new TestRemote(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const mint = (key: string) => {
|
||||
@@ -53,7 +52,7 @@ async function bench() {
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote'])
|
||||
expect(inject).toEqual(['slash', 'sessions', 'remote', 'remote.commands', 'locale'])
|
||||
})
|
||||
|
||||
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* gate, and the per-key ensureReady strong-wait policy.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { CommandDescriptor } from '../src/client/directory.ts'
|
||||
import { CommandDirectory } from '../src/client/directory.ts'
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
@@ -32,7 +31,7 @@ const S2_CMDS: CommandDescriptor[] = [
|
||||
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
|
||||
]
|
||||
|
||||
type ExecuteValue = { matched: boolean }
|
||||
type ExecuteValue = { matched: boolean; commandId?: string }
|
||||
|
||||
interface BenchOptions {
|
||||
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
|
||||
@@ -46,20 +45,26 @@ async function bench(opts: BenchOptions = {}) {
|
||||
const registered = new Map<string, SlashSource>()
|
||||
const listCalls: Array<{ sessionId: SessionId }> = []
|
||||
const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
|
||||
const api = {
|
||||
commands: {
|
||||
list: async (payload: { sessionId: SessionId }) => {
|
||||
listCalls.push(payload)
|
||||
const value = await (opts.commands ?? (p => Promise.resolve({
|
||||
commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
|
||||
})))(payload)
|
||||
return { result: { ok: true as const, value } }
|
||||
},
|
||||
execute: async (payload: { sessionId: SessionId; line: string }) => {
|
||||
executeCalls.push(payload)
|
||||
const value = await (opts.execute ?? (() => Promise.resolve({ matched: true })))(payload)
|
||||
return { result: { ok: true as const, value } }
|
||||
},
|
||||
// The service reads the generated commands Remote, which delivers the
|
||||
// carrier's outcome, so a programmed failure answers the error branch.
|
||||
const commandsRemote = {
|
||||
list: async (sessionId: SessionId) => {
|
||||
listCalls.push({ sessionId })
|
||||
const value = await (opts.commands ?? (p => Promise.resolve({
|
||||
commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
|
||||
})))({ sessionId })
|
||||
return { ok: true as const, value: value.commands }
|
||||
},
|
||||
execute: async (sessionId: SessionId, line: string) => {
|
||||
executeCalls.push({ sessionId, line })
|
||||
const fallback = (): Promise<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', {
|
||||
@@ -78,10 +83,20 @@ async function bench(opts: BenchOptions = {}) {
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('connection', { api })
|
||||
// CommandService injects `remote`; the directory invalidation arrives on the
|
||||
// same `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
const forwarded = new Map<string, Array<(...args: never[]) => void>>()
|
||||
ctx.provide('remote', {
|
||||
commands: commandsRemote,
|
||||
$on: (event: string, listener: (...args: never[]) => void) => {
|
||||
const listeners = forwarded.get(event) ?? []
|
||||
listeners.push(listener)
|
||||
forwarded.set(event, listeners)
|
||||
return () => { forwarded.set(event, listeners.filter(entry => entry !== listener)) }
|
||||
},
|
||||
$dispatch: (event: string, args: readonly unknown[]) => {
|
||||
for (const listener of forwarded.get(event) ?? []) listener(...args as never[])
|
||||
},
|
||||
})
|
||||
ctx.provide('remote.commands', commandsRemote)
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
|
||||
ctx.provide('conversation', {
|
||||
@@ -515,7 +530,13 @@ describe('detached admission notices', () => {
|
||||
mode = 'reject'
|
||||
menuPick(source, 'plan', proj('s1'))
|
||||
await flush()
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }])
|
||||
// A dead Remote call and a rejected one now read alike: both arrive as a
|
||||
// failed result, so the notice names the endpoint either way.
|
||||
expect(notices).toEqual([{
|
||||
scope: sid('s1'),
|
||||
level: 'error',
|
||||
text: 'command.execute failed: internal: network down',
|
||||
}])
|
||||
})
|
||||
|
||||
it('a torn-down scope drops the failure notice', async () => {
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.ts'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
@@ -98,7 +98,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
|
||||
}) as never)
|
||||
const sessions = new SessionsService(ctx, api) // provides 'sessions' itself
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote()) // provides 'sessions' itself
|
||||
await sessions.refresh()
|
||||
await Promise.resolve() // manager notifier flush
|
||||
await ctx.plugin(SlashService).await()
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
@@ -45,7 +45,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
@@ -57,7 +57,7 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* projection pair through the standard-kit `useProjection`; zero client-side
|
||||
* plan state.
|
||||
*/
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -39,8 +39,8 @@ export interface PlanChipInjected {
|
||||
exitPlanMode: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/** Required services: the seat's slot registry, transport, and locale registry. */
|
||||
export const inject = ['slots', 'connection', 'locale']
|
||||
/** Required services: the seat's slot registry, commands Remote, and locale registry. */
|
||||
export const inject = ['slots', 'remote', 'remote.commands', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the plan chip over the command channel.
|
||||
@@ -55,10 +55,9 @@ export function apply(ctx: ClientContext): void {
|
||||
inject: (sessionId: SessionId): PlanChipInjected => ({
|
||||
// Failure strings stay English (error-surface policy: not localized).
|
||||
exitPlanMode: async () => {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
|
||||
const result = await ctx.remote.commands.execute(sessionId, '/plan off')
|
||||
if (!result.ok) return `${result.error.message} (${result.error.code})`
|
||||
if (!result.value.matched) return 'unknown command: /plan off'
|
||||
if (result.value === undefined) return 'unknown command: /plan off'
|
||||
return null
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -25,16 +25,18 @@ async function bench() {
|
||||
name: 'root',
|
||||
children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) =>
|
||||
Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } }))
|
||||
ctx.provide('connection', { api: { commands: { execute } } })
|
||||
const execute = vi.fn((_sessionId: SessionId, _line: string) =>
|
||||
Promise.resolve({ commandId: 'c1', result: { kind: 'success' as const } }))
|
||||
const commandsRemote = { execute }
|
||||
ctx.provide('remote', { commands: commandsRemote })
|
||||
ctx.provide('remote.commands', commandsRemote)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
return { ctx, slots, execute }
|
||||
}
|
||||
|
||||
describe('ui-plan browser apply', () => {
|
||||
it('declares every service it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'connection', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'locale'])
|
||||
})
|
||||
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
@@ -44,7 +46,8 @@ describe('ui-plan browser apply', () => {
|
||||
it('waits until conversation declares the plan seat', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('connection', {})
|
||||
ctx.provide('remote', { commands: {} })
|
||||
ctx.provide('remote.commands', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
@@ -65,18 +68,17 @@ describe('ui-plan browser apply', () => {
|
||||
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
|
||||
|
||||
await expect(injected.exitPlanMode()).resolves.toBeNull()
|
||||
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
|
||||
expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off')
|
||||
|
||||
// Business failure folds to the composer-visible line.
|
||||
b.execute.mockResolvedValueOnce({
|
||||
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
|
||||
} as never)
|
||||
// Business failure folds to the composer-visible line: the generated method
|
||||
// throws with the RPC failure as its cause.
|
||||
b.execute.mockRejectedValueOnce(new Error('client api: commands/execute failed', {
|
||||
cause: { code: 'session-not-found', message: 'gone', details: {} },
|
||||
}))
|
||||
await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)')
|
||||
|
||||
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
|
||||
b.execute.mockResolvedValueOnce({
|
||||
result: { ok: true as const, value: { matched: false as const } },
|
||||
} as never)
|
||||
b.execute.mockResolvedValueOnce(undefined as never)
|
||||
await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off')
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user