mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(client): replace host description consumers
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { openNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
@@ -67,12 +67,16 @@ declare module '@deepseek-ai/cordis' {
|
||||
export interface Config {
|
||||
/** Maximum cold Session artifact size eligible for one full projection observation. */
|
||||
readonly coldBlankProbeMaxBytes?: number
|
||||
/** Override platform desktop-opener detection. */
|
||||
readonly nativeOpen?: boolean
|
||||
}
|
||||
|
||||
/** Host integrations replaceable by direct unit tests. */
|
||||
export interface SessionControllerInternals {
|
||||
/** Native default-application handoff. */
|
||||
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Native handoff availability probe. */
|
||||
readonly canOpenPath?: () => boolean
|
||||
}
|
||||
|
||||
/** Host service backing the generated `ctx.remote.session` namespace. */
|
||||
@@ -91,6 +95,7 @@ export class SessionController extends TypertRemoteService {
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
|
||||
nativeOpen: z.boolean(),
|
||||
})
|
||||
|
||||
private readonly agents: ApiSessionAgentController
|
||||
@@ -99,6 +104,7 @@ export class SessionController extends TypertRemoteService {
|
||||
private readonly history: SessionHistoryController
|
||||
private readonly listState: ApiSessionList
|
||||
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
|
||||
private readonly canOpenPath: () => boolean
|
||||
private readonly promotions = new Set<Promise<void>>()
|
||||
|
||||
/**
|
||||
@@ -122,6 +128,8 @@ export class SessionController extends TypertRemoteService {
|
||||
config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
|
||||
)
|
||||
this.openPath = internals.openPath ?? openNativePath
|
||||
this.canOpenPath = internals.canOpenPath
|
||||
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
|
||||
ctx.plugin(SessionFileReferences)
|
||||
ctx.plugin(SessionSkillCatalog)
|
||||
|
||||
@@ -242,6 +250,15 @@ export class SessionController extends TypertRemoteService {
|
||||
return buildModelCatalog(this.ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether this deployment can hand a Session workspace path to a native desktop.
|
||||
* @returns true when the matching open operation is available.
|
||||
*/
|
||||
@Remote
|
||||
canOpenWorkspacePath(): boolean {
|
||||
return this.canOpenPath()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one path prepared by a Session-aware caller on the Host desktop.
|
||||
* @param request - path after best-effort Session workspace resolution.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConnectionGeneration,
|
||||
ConnectionHandle,
|
||||
HostDescription,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
RemoteStreamCarrierError,
|
||||
@@ -16,13 +16,7 @@ import * as SessionClient from '../src/client/index.ts'
|
||||
import { ClientSessions } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
|
||||
|
||||
const DESCRIPTION: HostDescription = {
|
||||
version: 'fixture',
|
||||
cwd: '/fixture',
|
||||
attachedSessions: 0,
|
||||
home: '/home/fixture',
|
||||
canOpenPath: true,
|
||||
}
|
||||
const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } }
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
|
||||
@@ -34,7 +28,7 @@ interface Bench {
|
||||
readonly fiber: Fiber
|
||||
readonly sessions: ClientSessions
|
||||
dispatch(event: string, ...args: unknown[]): void
|
||||
publishHost(description: HostDescription | undefined): void
|
||||
publishGeneration(generation: ConnectionGeneration | undefined): void
|
||||
}
|
||||
|
||||
const contexts = new Set<Context>()
|
||||
@@ -45,41 +39,22 @@ afterEach(async () => {
|
||||
contexts.clear()
|
||||
})
|
||||
|
||||
async function mount(initialHost?: HostDescription): Promise<Bench> {
|
||||
async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
contexts.add(ctx)
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const remote = fakeRemote(api)
|
||||
const listeners = new Map<string, Set<RemoteListener>>()
|
||||
const hostListeners = new Set<() => void>()
|
||||
let host = initialHost
|
||||
const generationListeners = new Set<() => void>()
|
||||
let generation = initialGeneration
|
||||
const connection: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
hostDescription: {
|
||||
getSnapshot: () => host,
|
||||
subscribe: (listener) => {
|
||||
hostListeners.add(listener)
|
||||
return () => { hostListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
generation: {
|
||||
getSnapshot: () => host === undefined
|
||||
? undefined
|
||||
: { id: 1, host: { home: host.home } },
|
||||
getSnapshot: () => generation,
|
||||
subscribe: (listener) => {
|
||||
hostListeners.add(listener)
|
||||
return () => { hostListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
generation: {
|
||||
getSnapshot: () => host === undefined
|
||||
? undefined
|
||||
: { id: 1, host: { home: host.home } },
|
||||
subscribe: (listener) => {
|
||||
hostListeners.add(listener)
|
||||
return () => { hostListeners.delete(listener) }
|
||||
generationListeners.add(listener)
|
||||
return () => { generationListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
rpc: {
|
||||
@@ -115,9 +90,9 @@ async function mount(initialHost?: HostDescription): Promise<Bench> {
|
||||
dispatch: (event, ...args) => {
|
||||
for (const listener of listeners.get(event) ?? []) listener(...args as never[])
|
||||
},
|
||||
publishHost: (description) => {
|
||||
host = description
|
||||
for (const listener of [...hostListeners]) listener()
|
||||
publishGeneration: (next) => {
|
||||
generation = next
|
||||
for (const listener of [...generationListeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -166,7 +141,7 @@ describe('Session Controller Client apply', () => {
|
||||
it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => {
|
||||
const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bench = await mount(DESCRIPTION)
|
||||
const bench = await mount(GENERATION)
|
||||
await flush()
|
||||
|
||||
expect(accept).toHaveBeenCalledWith({
|
||||
@@ -198,7 +173,7 @@ describe('Session Controller Client apply', () => {
|
||||
})
|
||||
|
||||
it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => {
|
||||
const bench = await mount(DESCRIPTION)
|
||||
const bench = await mount(GENERATION)
|
||||
await flush()
|
||||
expect(bench.sessions.list.getSnapshot().phase).toBe('ready')
|
||||
|
||||
@@ -230,7 +205,7 @@ describe('Session Controller Client apply', () => {
|
||||
await flush()
|
||||
expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1)
|
||||
|
||||
bench.publishHost(DESCRIPTION)
|
||||
bench.publishGeneration(GENERATION)
|
||||
await flush()
|
||||
expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// Test-local programmable Remote fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl.
|
||||
import type {
|
||||
IApiClient, MessageId,
|
||||
MessageId,
|
||||
RpcError, RpcResponse, SessionId, SessionSearchItem,
|
||||
SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt,
|
||||
WorkspaceId, WorkspaceView,
|
||||
@@ -122,7 +122,7 @@ export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes {
|
||||
return api.sessionRemotes()
|
||||
}
|
||||
|
||||
export class FakeApiClient implements IApiClient {
|
||||
export class FakeApiClient {
|
||||
/** Chronological call record: [method, payload]. */
|
||||
readonly calls: { method: string; payload: unknown }[] = []
|
||||
/** Session ids in physical follow-generation opening order. */
|
||||
@@ -157,16 +157,6 @@ export class FakeApiClient implements IApiClient {
|
||||
onOpenWorkspacePath: (payload: unknown) => Promise<RemoteResult<{ opened: true }>> =
|
||||
() => Promise.resolve(remoteOk({ opened: true as const }))
|
||||
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{
|
||||
version: string
|
||||
cwd: string
|
||||
attachedSessions: number
|
||||
home: string
|
||||
canOpenPath: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({
|
||||
version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
|
||||
}))
|
||||
private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
|
||||
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
|
||||
private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
|
||||
@@ -191,10 +181,6 @@ export class FakeApiClient implements IApiClient {
|
||||
onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
|
||||
= () => Promise.resolve(remoteOk({ accepted: true as const }))
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
}
|
||||
|
||||
onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
|
||||
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true }))
|
||||
|
||||
@@ -223,6 +209,7 @@ export class FakeApiClient implements IApiClient {
|
||||
execute: () => Promise.resolve({ ok: true, value: undefined }),
|
||||
},
|
||||
session: {
|
||||
canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)),
|
||||
list: payload => this.remoteResult('session.list', payload, this.onList(payload)),
|
||||
modelCatalog: () => Promise.resolve({
|
||||
ok: true,
|
||||
|
||||
@@ -15,6 +15,39 @@ async function context(): Promise<Context> {
|
||||
}
|
||||
|
||||
describe('session/openWorkspacePath', () => {
|
||||
it('reports the deployment opener capability independently of a Session', async () => {
|
||||
const ctx = await context()
|
||||
const remote = createSessionTestRemote(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/default',
|
||||
canOpenPath: () => false,
|
||||
})
|
||||
|
||||
await expect(remote.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false })
|
||||
})
|
||||
|
||||
it('derives opener availability from config, an injected opener, or the platform probe', async () => {
|
||||
const configured = createSessionTestRemote(await context(), {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/default',
|
||||
nativeOpen: false,
|
||||
})
|
||||
await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false })
|
||||
|
||||
const injected = createSessionTestRemote(await context(), {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/default',
|
||||
openPath: () => Promise.resolve(),
|
||||
})
|
||||
await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true })
|
||||
|
||||
const detected = createSessionTestRemote(await context(), {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/default',
|
||||
})
|
||||
await expect(detected.canOpenWorkspacePath()).resolves.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('hands a Client-resolved workspace path to the Host opener unchanged', async () => {
|
||||
const ctx = await context()
|
||||
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
|
||||
/** Direct test face matching the generated `ctx.remote.session` unary methods. */
|
||||
export interface TestSessionRemote {
|
||||
canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
|
||||
list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
|
||||
search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
|
||||
create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
|
||||
@@ -76,8 +77,10 @@ export interface TestSessionRemoteDefaults {
|
||||
readonly defaultModelSelection: () => AgentModelSelection
|
||||
readonly cwd: string
|
||||
readonly coldBlankProbeMaxBytes?: number
|
||||
readonly nativeOpen?: boolean
|
||||
readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
|
||||
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
readonly canOpenPath?: () => boolean
|
||||
}
|
||||
|
||||
const installed = new WeakMap<Context, SessionController>()
|
||||
@@ -185,10 +188,16 @@ function installControllers(
|
||||
try {
|
||||
controller = new SessionController(
|
||||
ctx,
|
||||
defaults.coldBlankProbeMaxBytes === undefined
|
||||
? {}
|
||||
: { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes },
|
||||
defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
|
||||
{
|
||||
...defaults.coldBlankProbeMaxBytes === undefined
|
||||
? {}
|
||||
: { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes },
|
||||
...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen },
|
||||
},
|
||||
{
|
||||
...defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
|
||||
...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath },
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
cwd.mockRestore()
|
||||
@@ -233,6 +242,7 @@ export function createSessionTestRemote(
|
||||
): TestSessionRemote {
|
||||
const direct = createSessionTestController(ctx, defaults)
|
||||
return {
|
||||
canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()),
|
||||
list: (request, signal = new AbortController().signal) => remoteResult(
|
||||
() => direct.list(request, signal),
|
||||
signal,
|
||||
|
||||
@@ -128,6 +128,15 @@ export class SettingsController extends TypertRemoteService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether this deployment can open an authored Agent preset directory natively.
|
||||
* @returns true when the matching open operation is available.
|
||||
*/
|
||||
@Remote
|
||||
canOpenAgentPresetDirectory(): boolean {
|
||||
return this.canOpenPath()
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into one namespace's stored user section.
|
||||
* @param ns - namespace key to write.
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
expect(controller.typertRemote.namespace).toBe('settings')
|
||||
expect(remoteMethods(controller)).toEqual([
|
||||
{ method: 'describe', invocation: { kind: 'direct' } },
|
||||
{ method: 'canOpenAgentPresetDirectory', invocation: { kind: 'direct' } },
|
||||
{ method: 'update', invocation: { kind: 'direct' } },
|
||||
{ method: 'replace', invocation: { kind: 'direct' } },
|
||||
{ method: 'mutate', invocation: { kind: 'direct' } },
|
||||
@@ -348,6 +349,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
} as never)
|
||||
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
|
||||
const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath })
|
||||
expect(openable.canOpenAgentPresetDirectory()).toBe(true)
|
||||
const signal = new AbortController().signal
|
||||
await expect(openable.openAgentPresetDirectory('mine', signal))
|
||||
.resolves.toEqual({ opened: true })
|
||||
@@ -360,6 +362,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
|
||||
}),
|
||||
} as never)
|
||||
const reveal = new SettingsController(headless, { nativeOpen: false })
|
||||
expect(reveal.canOpenAgentPresetDirectory()).toBe(false)
|
||||
await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal))
|
||||
.resolves.toEqual({ opened: false, path: '/presets/mine' })
|
||||
})
|
||||
|
||||
@@ -197,18 +197,7 @@ async function waitFor(check: () => void): Promise<void> {
|
||||
|
||||
function provideClientServices(ctx: Context, remote: WorkspaceRemote): void {
|
||||
const connection: ConnectionHandle = {
|
||||
api: {} as ConnectionHandle['api'],
|
||||
isLoopback: true,
|
||||
hostDescription: {
|
||||
getSnapshot: () => ({
|
||||
version: 'fixture',
|
||||
cwd: '/fixture',
|
||||
attachedSessions: 0,
|
||||
home: '/home/fixture',
|
||||
canOpenPath: true,
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
generation: AVAILABLE_CONNECTION.generation,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* before-the-fact, while the header only reports what a session already runs.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: pulls the Session Controller service merge (ctx.sessions).
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
@@ -51,7 +50,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = [
|
||||
'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
|
||||
'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -59,13 +58,12 @@ export const inject = [
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const settingsWire = { settings: ctx.remote.settings }
|
||||
const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe())
|
||||
// One roster, four surfaces. The chip is registered in a later scope, so it
|
||||
// subscribes here rather than being reached from this one.
|
||||
const rosterReaders = new Set<() => void>()
|
||||
const section = new AgentPresetSectionController(api, ctx.remote, () => {
|
||||
const section = new AgentPresetSectionController(ctx.remote, () => {
|
||||
void controller.load()
|
||||
for (const read of rosterReaders) read()
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* more than the row it targeted.
|
||||
*/
|
||||
|
||||
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
@@ -133,7 +133,6 @@ export class AgentPresetSectionController {
|
||||
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
||||
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'host'>,
|
||||
private readonly remote: Pick<ClientRemote, 'agentPresets' | 'settings'>,
|
||||
/**
|
||||
* Called after this page changes the roster DIRECTORY, so the other
|
||||
@@ -168,13 +167,13 @@ export class AgentPresetSectionController {
|
||||
// Issued together: one round trip decides the page, and a load that waited
|
||||
// for them in turn would hold the section in `loading` twice as long,
|
||||
// where a concurrent reload silently returns instead of refreshing.
|
||||
const opener = this.api.host.describe({})
|
||||
const opener = this.remote.settings.canOpenAgentPresetDirectory()
|
||||
const roster = await beginRosterRead(this.remote, this.store)
|
||||
// A refused describe leaves the reveal-the-path path, which needs no opener.
|
||||
const described = await opener.catch(() => undefined)
|
||||
if (roster === undefined) return
|
||||
const { presets, authorable } = roster
|
||||
const hasDocument = described?.result.ok === true && described.result.value.canOpenPath
|
||||
const hasDocument = described?.ok === true && described.value
|
||||
if (presets.length === 0) {
|
||||
// Nothing to manage leaves nothing to keep a dialog open over.
|
||||
this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })
|
||||
|
||||
@@ -74,6 +74,7 @@ async function bench() {
|
||||
// The row reads `describe` to learn whether this browser may write at all,
|
||||
// and its default write is the one op this spec records.
|
||||
const settings = {
|
||||
canOpenAgentPresetDirectory: () => Promise.resolve({ ok: true as const, value: true }),
|
||||
describe: () => Promise.resolve({
|
||||
ok: true as const,
|
||||
value: { writable: true, hasDocument: true, namespaces: [] },
|
||||
@@ -114,16 +115,7 @@ async function bench() {
|
||||
}
|
||||
ctx.provide('remote.agentPresets', agentPresets as never)
|
||||
Object.assign(remote, { agentPresets })
|
||||
ctx.provide('connection', {
|
||||
api: {
|
||||
host: {
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { canOpenPath: true } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
ctx.provide('connection', { isLoopback: true } as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote }
|
||||
}
|
||||
@@ -185,7 +177,7 @@ function sessionsDouble(state: {
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual([
|
||||
'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
|
||||
'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
|
||||
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
|
||||
|
||||
@@ -41,36 +41,16 @@ interface FakeOptions {
|
||||
authorable?: boolean
|
||||
/** Whether the host can open a preset directory on a desktop. */
|
||||
hasDocument?: boolean
|
||||
/** Reject `host.describe`, as a dead transport does. */
|
||||
throwDescribe?: boolean
|
||||
/** Reject the opener capability read, as a dead transport does. */
|
||||
throwCapability?: boolean
|
||||
/** Hold `remove` until this resolves, to observe the in-flight state. */
|
||||
holdRemove?: Promise<void>
|
||||
}
|
||||
|
||||
const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } })
|
||||
const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value })
|
||||
const remoteFail = (message: string) =>
|
||||
Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } })
|
||||
|
||||
/**
|
||||
* The carried wire face: the desktop opener, the default write, and the opener
|
||||
* capability the page joins onto the roster.
|
||||
* @param defaultId - the preset a session with no choice gets.
|
||||
* @param options - failure injection and call recording.
|
||||
* @returns the fake client.
|
||||
*/
|
||||
function fakeApi(
|
||||
options: FakeOptions = {},
|
||||
): Pick<IApiClient, 'host'> {
|
||||
return {
|
||||
host: {
|
||||
describe: () => (options.throwDescribe === true
|
||||
? Promise.reject(new Error('socket closed'))
|
||||
: ok({ canOpenPath: options.hasDocument ?? true })),
|
||||
},
|
||||
} as Pick<IApiClient, 'host'>
|
||||
}
|
||||
|
||||
/**
|
||||
* The Remote namespace over an in-memory preset store: copies land, so the
|
||||
* roster the controller re-reads after a copy is the one the copy produced.
|
||||
@@ -143,6 +123,12 @@ function fakeRemote(
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
canOpenAgentPresetDirectory: () => {
|
||||
record('canOpenAgentPresetDirectory', {})
|
||||
return options.throwCapability === true
|
||||
? Promise.reject(new Error('socket closed'))
|
||||
: remoteOk(options.hasDocument ?? true)
|
||||
},
|
||||
update: (ns: string, patch: { default?: string }) => {
|
||||
record('settings.update', { ns, patch })
|
||||
if (options.failSettings !== undefined) return remoteFail(options.failSettings)
|
||||
@@ -176,7 +162,6 @@ function harness(options: FakeOptions = {}) {
|
||||
let rosterChanges = 0
|
||||
const wired = { ...options, calls: options.calls ?? calls }
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeApi(wired),
|
||||
fakeRemote(presets, defaultId, wired),
|
||||
() => { rosterChanges += 1 },
|
||||
)
|
||||
@@ -191,11 +176,11 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft {
|
||||
|
||||
describe('loading the roster', () => {
|
||||
it('still lists the roster when the opener capability cannot be read', async () => {
|
||||
const { controller } = harness({ throwDescribe: true })
|
||||
const { controller } = harness({ throwCapability: true })
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The two reads are independent: a refused `host.describe` costs the
|
||||
// The two reads are independent: a refused capability query costs the
|
||||
// open-directory affordance, not the page.
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
@@ -572,7 +557,6 @@ describe('deleting', () => {
|
||||
await controller.load()
|
||||
presets.clear()
|
||||
const broken = new AgentPresetSectionController(
|
||||
{ host: {} } as unknown as Pick<IApiClient, 'host'>,
|
||||
{
|
||||
agentPresets: {
|
||||
list: () => Promise.reject(new Error('gone')),
|
||||
@@ -596,7 +580,7 @@ describe('a controller with no roster listener', () => {
|
||||
const presets = seed()
|
||||
const defaultId = { id: 'standard' }
|
||||
const alone = new AgentPresetSectionController(
|
||||
fakeApi(), fakeRemote(presets, defaultId))
|
||||
fakeRemote(presets, defaultId))
|
||||
await alone.load()
|
||||
alone.confirmDelete('mine')
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-chat",
|
||||
@@ -47,6 +48,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:^",
|
||||
@@ -58,8 +60,10 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { basename } from './turn-deliverables.ts'
|
||||
import type { NS } from './locales.ts'
|
||||
@@ -44,9 +43,11 @@ export function fitProducedFiles(
|
||||
export interface ProducedFilesInjected {
|
||||
/** Whether the browser itself is connected over loopback. */
|
||||
isLoopback: boolean
|
||||
/** Load the opener capability when this row first reaches the page. */
|
||||
ensureWorkspacePathOpen(): void
|
||||
hooks: {
|
||||
/** Current generation's Host description, bound by the slot renderer. */
|
||||
hostDescription: HostDescriptionSource
|
||||
/** Current generation's Session workspace opener capability. */
|
||||
workspacePathOpen: HostObservable<boolean | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +66,10 @@ function moreLabel(t: ProducedFilesProps['t'], count: number): string {
|
||||
* @returns The produced-files row.
|
||||
*/
|
||||
export function ProducedFiles({
|
||||
matched: paths, openFile, isLoopback, useHostDescription, t,
|
||||
matched: paths, openFile, isLoopback, ensureWorkspacePathOpen, useWorkspacePathOpen, t,
|
||||
}: ProducedFilesProps) {
|
||||
const hostCanOpenPath = useHostDescription(description => description?.canOpenPath === true)
|
||||
useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen])
|
||||
const hostCanOpenPath = useWorkspacePathOpen(available => available === true)
|
||||
const canOpenPath = isLoopback && hostCanOpenPath
|
||||
const limit = Math.min(paths.length, SHOWN_LIMIT)
|
||||
const [shownCount, setShownCount] = useState(limit)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
*/
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -30,7 +32,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
|
||||
export { producedForClosing } from './turn-deliverables.ts'
|
||||
|
||||
/** Required services for the tail-slot registration and its dictionaries. */
|
||||
export const inject = ['slots', 'locale', 'uiConversation', 'connection']
|
||||
export const inject = ['slots', 'locale', 'uiConversation', 'connection', 'remote', 'remote.session']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dictionaries and the turn-tail entry.
|
||||
@@ -38,6 +40,34 @@ export const inject = ['slots', 'locale', 'uiConversation', 'connection']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const workspacePathOpen = createSnapshotStore<boolean | undefined>(undefined)
|
||||
let requestedWorkspacePathOpen = false
|
||||
let capabilityRevision = 0
|
||||
let pendingCapability: Promise<void> | undefined
|
||||
const loadWorkspacePathOpen = (): void => {
|
||||
if (pendingCapability !== undefined) return
|
||||
const revision = capabilityRevision
|
||||
const pending = ctx.remote.session.canOpenWorkspacePath()
|
||||
.then((result) => {
|
||||
if (revision === capabilityRevision) workspacePathOpen.set(result.ok && result.value)
|
||||
}, () => {
|
||||
if (revision === capabilityRevision) workspacePathOpen.set(false)
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingCapability === pending) pendingCapability = undefined
|
||||
})
|
||||
pendingCapability = pending
|
||||
}
|
||||
const ensureWorkspacePathOpen = (): void => {
|
||||
requestedWorkspacePathOpen = true
|
||||
if (workspacePathOpen.getSnapshot() === undefined) loadWorkspacePathOpen()
|
||||
}
|
||||
ctx.on('connection/reset', () => {
|
||||
capabilityRevision++
|
||||
pendingCapability = undefined
|
||||
workspacePathOpen.set(undefined)
|
||||
if (requestedWorkspacePathOpen) loadWorkspacePathOpen()
|
||||
})
|
||||
ctx.uiConversation.events.register(deliverablesDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
|
||||
ctx.slots.inject(
|
||||
@@ -48,7 +78,8 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
inject: () => ({
|
||||
isLoopback: connection.isLoopback,
|
||||
hooks: { hostDescription: connection.hostDescription },
|
||||
ensureWorkspacePathOpen,
|
||||
hooks: { workspacePathOpen },
|
||||
}),
|
||||
}, ProducedFiles),
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c
|
||||
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
fitProducedFiles, ProducedFiles, type ProducedFilesProps,
|
||||
fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps,
|
||||
} from '../src/client/ProducedFiles.tsx'
|
||||
import {
|
||||
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
||||
@@ -403,13 +403,11 @@ describe('ProducedFiles row', () => {
|
||||
const capability = (
|
||||
canOpenPath: boolean | undefined,
|
||||
isLoopback = true,
|
||||
): Pick<ProducedFilesProps, 'isLoopback' | 'useHostDescription'> => {
|
||||
const description = canOpenPath === undefined
|
||||
? undefined
|
||||
: { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath }
|
||||
): Pick<ProducedFilesProps, 'isLoopback' | 'ensureWorkspacePathOpen' | 'useWorkspacePathOpen'> => {
|
||||
return {
|
||||
isLoopback,
|
||||
useHostDescription: selector => selector(description),
|
||||
ensureWorkspacePathOpen: () => {},
|
||||
useWorkspacePathOpen: selector => selector(canOpenPath),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,14 +578,17 @@ describe('plugin registration', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
const hostDescription = { getSnapshot: () => undefined, subscribe: () => () => {} }
|
||||
const generation = { getSnapshot: () => undefined, subscribe: () => () => {} }
|
||||
ctx.provide('connection', {
|
||||
api: { settings: {} },
|
||||
isLoopback: false,
|
||||
hostDescription,
|
||||
generation,
|
||||
} as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
const session = {
|
||||
canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }),
|
||||
}
|
||||
ctx.provide('remote', { $on: () => () => {}, session } as never)
|
||||
ctx.provide('remote.session', session as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
|
||||
@@ -595,7 +596,16 @@ describe('plugin registration', () => {
|
||||
await fiber.await()
|
||||
const [entry] = ctx.slots.entries('conversation.chat.turnTail')
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry?.inject?.()).toEqual({ isLoopback: false, hooks: { hostDescription } })
|
||||
const injected = entry?.inject?.() as unknown as ProducedFilesInjected
|
||||
expect(injected.isLoopback).toBe(false)
|
||||
expect(typeof injected.ensureWorkspacePathOpen).toBe('function')
|
||||
expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined()
|
||||
ctx.emit('connection/reset')
|
||||
injected.ensureWorkspacePathOpen()
|
||||
await vi.waitFor(() => {
|
||||
expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true)
|
||||
})
|
||||
injected.ensureWorkspacePathOpen()
|
||||
|
||||
// The prose face is live while the plugin is: a produced turn yields a
|
||||
// resolver whose matches open through the owner-supplied opener.
|
||||
@@ -617,4 +627,52 @@ describe('plugin registration', () => {
|
||||
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
|
||||
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('queries the workspace opener lazily and replaces stale results after reconnect', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
new UiConversation(ctx, { binding: () => undefined } as never)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
ctx.provide('connection', {
|
||||
isLoopback: true,
|
||||
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
} as never)
|
||||
const first = Promise.withResolvers<{ ok: true; value: boolean }>()
|
||||
const second = Promise.withResolvers<{ ok: true; value: boolean }>()
|
||||
const staleFailure = Promise.withResolvers<{ ok: true; value: boolean }>()
|
||||
const capability = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
.mockReturnValueOnce(staleFailure.promise)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const session = { canOpenWorkspacePath: capability }
|
||||
ctx.provide('remote', { $on: () => () => {}, session } as never)
|
||||
ctx.provide('remote.session', session as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const entry = ctx.slots.entries('conversation.chat.turnTail')[0]
|
||||
const injected = entry?.inject?.() as unknown as ProducedFilesInjected
|
||||
|
||||
injected.ensureWorkspacePathOpen()
|
||||
injected.ensureWorkspacePathOpen()
|
||||
expect(capability).toHaveBeenCalledOnce()
|
||||
ctx.emit('connection/reset')
|
||||
expect(capability).toHaveBeenCalledTimes(2)
|
||||
first.resolve({ ok: true, value: false })
|
||||
await Promise.resolve()
|
||||
expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined()
|
||||
second.resolve({ ok: true, value: true })
|
||||
await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) })
|
||||
|
||||
ctx.emit('connection/reset')
|
||||
ctx.emit('connection/reset')
|
||||
staleFailure.reject(new Error('stale offline'))
|
||||
await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) })
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
@@ -17,6 +20,9 @@
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@ export function apply(ctx: ClientContext): void {
|
||||
// when there is no header to carry it.
|
||||
const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined
|
||||
const now = Date.now()
|
||||
const home = connection.hostDescription.getSnapshot()?.home
|
||||
const home = connection.generation.getSnapshot()?.host.home
|
||||
const listed = sessions.list.getSnapshot().byId
|
||||
return [
|
||||
...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)),
|
||||
|
||||
@@ -92,7 +92,7 @@ async function bench(
|
||||
ctx.provide('remote.fileReferences', { list: files })
|
||||
ctx.provide('remote.sessionReferenceResolver', { candidates: sessions })
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
ctx.provide('connection', { hostDescription: { getSnapshot: () => ({ home: HOME }) } })
|
||||
ctx.provide('connection', { generation: { getSnapshot: () => ({ id: 1, host: { home: HOME } }) } })
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
@@ -124,7 +124,7 @@ describe('apply', () => {
|
||||
ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) })
|
||||
ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) })
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined } })
|
||||
ctx.provide('connection', { generation: { getSnapshot: () => undefined } })
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } })
|
||||
const ownFiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await ownFiber.await()
|
||||
|
||||
@@ -24,7 +24,7 @@ export const inject = ['slots', 'connection']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } })
|
||||
const toolInject = () => ({ hooks: { connectionGeneration: connection.generation } })
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'tool-call',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Tool UI slot declarations and their composed component props. */
|
||||
import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -47,10 +47,10 @@ export interface ToolCallOwnerProps {
|
||||
export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
|
||||
|
||||
/** Injected Host description for POSIX home-path display. */
|
||||
export type ToolHostDescriptionInjected = {
|
||||
export type ToolConnectionGenerationInjected = {
|
||||
hooks: {
|
||||
/** Current generation's Host description, bound by the slot renderer. */
|
||||
hostDescription: HostDescriptionSource
|
||||
/** Current Connection generation, bound by the slot renderer. */
|
||||
connectionGeneration: ConnectionGenerationState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +58,9 @@ export type ToolHostDescriptionInjected = {
|
||||
export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
|
||||
& PropsRenderSlots<'tool.call.toolview'>
|
||||
& PropsLocale<'conversation'>
|
||||
& InjectFace<ToolHostDescriptionInjected>
|
||||
& InjectFace<ToolConnectionGenerationInjected>
|
||||
|
||||
/** Full props of the selected Tool output renderer in the details panel. */
|
||||
export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'>
|
||||
& PropsLocale<'conversation'>
|
||||
& InjectFace<ToolHostDescriptionInjected>
|
||||
& InjectFace<ToolConnectionGenerationInjected>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */
|
||||
export { apply, inject } from './apply.ts'
|
||||
export type {
|
||||
ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps,
|
||||
ToolCallOwnerProps, ToolCallViewProps, ToolConnectionGenerationInjected, ToolDetailsProps, ToolTreeProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
@@ -93,9 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({
|
||||
* @returns the Tool call tree.
|
||||
*/
|
||||
export function ToolCallTree({
|
||||
renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t,
|
||||
renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useConnectionGeneration, t,
|
||||
}: ToolTreeProps) {
|
||||
const home = useHostDescription(description => description?.home)
|
||||
const home = useConnectionGeneration(generation => generation?.host.home)
|
||||
const block = node.data.root
|
||||
return (
|
||||
<ToolCallBranch
|
||||
|
||||
@@ -21,9 +21,9 @@ import css from './ToolDetails.module.css'
|
||||
* @returns the details output body.
|
||||
*/
|
||||
export function ToolDetails({
|
||||
block, cwd, useHostDescription, t,
|
||||
}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useHostDescription' | 't'>) {
|
||||
const home = useHostDescription(description => description?.home)
|
||||
block, cwd, useConnectionGeneration, t,
|
||||
}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useConnectionGeneration' | 't'>) {
|
||||
const home = useConnectionGeneration(generation => generation?.host.home)
|
||||
const terminalModel = terminalCardModel(block, cwd)
|
||||
if (terminalModel !== null) {
|
||||
const terminal = localizeTerminalCardModel(terminalModel, t)
|
||||
|
||||
@@ -140,7 +140,7 @@ type AskQuestionRowProps = ToolCallViewProps & PropsLocale<'conversation'>
|
||||
export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Composer verdicts settle the call as specific UserQuestionErrors
|
||||
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
|
||||
// (ask_user_question handler): 'ASK_CANCELLED' is the user's own
|
||||
// dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
|
||||
// question was pending. Both name their verdict instead of the generic
|
||||
// failed shape, and the abort keeps the shared stopped (amber) semantics of
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('AskQuestionRow', () => {
|
||||
})
|
||||
|
||||
it('user cancellation shows the original questions without raw JSON or an error body', () => {
|
||||
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
|
||||
// ASK_CANCELLED: the ask_user_question handler's cancel error.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
|
||||
{ isError: true, error: { name: 'UserQuestionError', code: 'ASK_CANCELLED' } }))} />)
|
||||
expect(screen.getByText('已取消')).toBeTruthy()
|
||||
@@ -184,7 +184,7 @@ describe('AskQuestionRow', () => {
|
||||
})
|
||||
|
||||
it('a turn abort shows the original questions with stopped semantics', () => {
|
||||
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
|
||||
// ASK_ABORTED: the ask handler's turn-abort settlement.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
|
||||
{ isError: true, error: { name: 'UserQuestionError', code: 'ASK_ABORTED' } }))} />)
|
||||
expect(screen.getByText('已中断')).toBeTruthy()
|
||||
|
||||
@@ -72,9 +72,8 @@ const LAYOUT_CHILDREN = {
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('connection', {
|
||||
api: { settings: {} },
|
||||
isLoopback: false,
|
||||
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
})
|
||||
new TestRemote(runtime.ctx, {
|
||||
session: {
|
||||
|
||||
@@ -121,9 +121,8 @@ async function bench(snapshot: ChatSnapshot) {
|
||||
ctx.provide('uiWorkspace', {} as never)
|
||||
new TestRemote(ctx, { session: { openWorkspacePath } })
|
||||
ctx.provide('connection', {
|
||||
api: { settings: {} },
|
||||
isLoopback: false,
|
||||
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
} as never)
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
|
||||
@@ -361,9 +361,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
it('abbreviates a leftover POSIX home path on the read card label', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })],
|
||||
}), target, '/tmp/ws', {
|
||||
version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false,
|
||||
})
|
||||
}), target, '/tmp/ws', { id: 1, host: { home: '/Users/u' } })
|
||||
expect(view.getByText('~/notes.md')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/** ToolCallTree-owned root/subcall markers and selection projection. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -23,7 +23,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => (
|
||||
function props(
|
||||
block: ToolResultNode,
|
||||
selectedCallId?: string,
|
||||
description?: HostDescription,
|
||||
generation?: ConnectionGeneration,
|
||||
owners?: ToolCallOwnerProps[],
|
||||
): ToolTreeProps {
|
||||
const snapshot = {} as SessionSnapshot
|
||||
@@ -50,7 +50,7 @@ function props(
|
||||
inspectCall: vi.fn(),
|
||||
forkAt: vi.fn(),
|
||||
fileMentions: vi.fn(),
|
||||
useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'],
|
||||
useConnectionGeneration: (selector => selector(generation)) as ToolTreeProps['useConnectionGeneration'],
|
||||
t,
|
||||
} as unknown as ToolTreeProps
|
||||
}
|
||||
@@ -98,9 +98,7 @@ describe('ToolCallTree', () => {
|
||||
|
||||
it('abbreviates a POSIX home path in the generic tool summary', () => {
|
||||
const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' })
|
||||
const view = render(<ToolCallTree {...props(block, 'w1', {
|
||||
version: '0', cwd: '/tmp', attachedSessions: 0, home: '/h', canOpenPath: false,
|
||||
})} />)
|
||||
const view = render(<ToolCallTree {...props(block, 'w1', { id: 1, host: { home: '/h' } })} />)
|
||||
expect(view.getByText('~/docs/a.ts')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Test adapter for the production conversation.details.tool registration. */
|
||||
import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
@@ -144,12 +144,12 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
|
||||
/**
|
||||
* Bind ui-tool's details renderer to the conversation slot callback shape.
|
||||
* @param t - conversation locale seat used by Tool cards.
|
||||
* @param description - optional Host description so the details card can abbreviate home paths.
|
||||
* @param generation - optional Connection generation carrying the Host home.
|
||||
* @returns a direct-test renderSlot implementation.
|
||||
*/
|
||||
export function renderToolDetails(
|
||||
t: TranslateNS<'conversation'>,
|
||||
description?: HostDescription,
|
||||
generation?: ConnectionGeneration,
|
||||
): DetailsSlotProps['renderSlot'] {
|
||||
return (_key, owner) => {
|
||||
// PropsRenderSlots keeps its key generic even for this one-key share;
|
||||
@@ -158,7 +158,7 @@ export function renderToolDetails(
|
||||
return <ToolDetails
|
||||
block={details.block}
|
||||
cwd={details.cwd}
|
||||
useHostDescription={selector => selector(description)}
|
||||
useConnectionGeneration={selector => selector(generation)}
|
||||
t={t}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -60,9 +60,8 @@ const LAYOUT_CHILDREN = {
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('connection', {
|
||||
api: { settings: {} },
|
||||
isLoopback: false,
|
||||
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
})
|
||||
const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } }))
|
||||
new TestRemote(runtime.ctx, { session: { openWorkspacePath } })
|
||||
@@ -207,9 +206,8 @@ describe('registrant declaration injection', () => {
|
||||
it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('connection', {
|
||||
api: { settings: {} },
|
||||
isLoopback: false,
|
||||
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
})
|
||||
new TestRemote(runtime.ctx, {
|
||||
session: {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* and a hole has exactly one declaring entry — they carry the same owner
|
||||
* contract and the same occupant.
|
||||
*/
|
||||
import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pull the owner SlotMap merges into programs that resolve the
|
||||
// runtime shares below.
|
||||
@@ -90,7 +90,7 @@ export type DirectoryPickingHooks = PropsHooks<DirectoryPickingInjected['hooks']
|
||||
export type WorkspaceBrowserInjected = {
|
||||
hooks: DirectoryPickingInjected['hooks'] & {
|
||||
/** Current generation's Host description, bound by the slot renderer. */
|
||||
hostDescription: HostDescriptionSource
|
||||
connectionGeneration: ConnectionGenerationState
|
||||
}
|
||||
/**
|
||||
* Start a New Session in a Workspace: reuse-or-create its blank session and
|
||||
|
||||
@@ -73,7 +73,7 @@ export function apply(ctx: Context): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = ctx.get('sessions') as ISessions
|
||||
const workspaces = ctx.get('workspaces') as IWorkspaces
|
||||
const hostDescription = connection.hostDescription
|
||||
const connectionGeneration = connection.generation
|
||||
const uiWorkspace = new UiWorkspaceService(
|
||||
ctx, ctx.remote.directoryPicker, workspaces, sessions)
|
||||
ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } })
|
||||
@@ -125,7 +125,7 @@ export function apply(ctx: Context): void {
|
||||
await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
createWorkspace: input => workspaces.create(input),
|
||||
hooks: { directoryFlow: browserFlowSource, hostDescription },
|
||||
hooks: { directoryFlow: browserFlowSource, connectionGeneration },
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => workspaces.create(input),
|
||||
|
||||
@@ -820,11 +820,11 @@ export function WorkspaceBrowser({
|
||||
searchSessions,
|
||||
searchResultLimit,
|
||||
useDirectoryFlow,
|
||||
useHostDescription,
|
||||
useConnectionGeneration,
|
||||
renderSlot,
|
||||
t,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const home = useHostDescription(description => description?.home)
|
||||
const home = useConnectionGeneration(generation => generation?.host.home)
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const workspacePhase = useWorkspaces(state => state.phase)
|
||||
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
|
||||
|
||||
@@ -59,7 +59,7 @@ async function bench() {
|
||||
fork,
|
||||
} as never)
|
||||
ctx.provide('connection', {
|
||||
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
} as never)
|
||||
const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' }))
|
||||
const directoryPicker = { pick: pickDirectory }
|
||||
@@ -162,7 +162,7 @@ describe('ui-workspace apply', () => {
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
expect(browser.hooks.hostDescription.getSnapshot()).toBeUndefined()
|
||||
expect(browser.hooks.connectionGeneration.getSnapshot()).toBeUndefined()
|
||||
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
// A flow occupant flips exactly its own surface, and the source notifies.
|
||||
const notified = vi.fn()
|
||||
|
||||
@@ -35,7 +35,7 @@ async function createRuntime(): Promise<SlotTestRuntime> {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.releaseWorkspaceSource()
|
||||
runtime.ctx.provide('connection', {
|
||||
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
})
|
||||
// The rename flow never picks a directory; the namespace only has to be there
|
||||
// for ui-workspace's inject to settle.
|
||||
|
||||
@@ -85,7 +85,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||
useHostDescription: selector => selector(undefined),
|
||||
useConnectionGeneration: selector => selector(undefined),
|
||||
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
|
||||
t,
|
||||
...overrides,
|
||||
@@ -110,9 +110,7 @@ describe('WorkspaceBrowser', () => {
|
||||
path: '/home/u/Documents/project',
|
||||
title: 'Project',
|
||||
}])),
|
||||
useHostDescription: selector => selector({
|
||||
version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false,
|
||||
}),
|
||||
useConnectionGeneration: selector => selector({ id: 1, host: { home: '/home/u' } }),
|
||||
})
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
|
||||
Reference in New Issue
Block a user