+
+
+ {!ready
+ ?
{t('readingSources')}
+ : sources.length === 0 ?
{t('noAgents')}
: null}
{running ?
{t('importing')}
: null}
{error === undefined ? null :
{t('failed', { message: error })}
}
{value === undefined ? null : (
diff --git a/packages/client/ui-settings-import-sessions/src/client/index.ts b/packages/client/ui-settings-import-sessions/src/client/index.ts
index c500365b64de..1a718bd8f9f4 100644
--- a/packages/client/ui-settings-import-sessions/src/client/index.ts
+++ b/packages/client/ui-settings-import-sessions/src/client/index.ts
@@ -69,7 +69,7 @@ export function apply(ctx: ClientContext): void {
const t = ctx.locale.bind(NS)
const injected = (): ImportSessionsSectionInjected => ({
- sourceRoot: () => ctx.harnessSessionsImport.sourceRoot(),
+ sessionSources: () => ctx.harnessSessionsImport.sessionSources(),
importSessions: request => ctx.harnessSessionsImport.importSessions(request),
exportSession: async (sessionId, title) => {
const markdown = await ctx.harnessSessionsImport.exportSession(sessionId)
diff --git a/packages/client/ui-settings-import-sessions/src/client/locales.ts b/packages/client/ui-settings-import-sessions/src/client/locales.ts
index 964cc4e33d3a..fc7101b5e0c5 100644
--- a/packages/client/ui-settings-import-sessions/src/client/locales.ts
+++ b/packages/client/ui-settings-import-sessions/src/client/locales.ts
@@ -3,9 +3,13 @@
/** Simplified Chinese dictionary and key source of truth. */
export const zh = {
nav: '会话管理',
- sourceRoot: '来源目录',
- sourceRootHint: '官方 DeepSeek Harness 的 sessions 目录',
- readingSourceRoot: '正在读取来源目录…',
+ sourceAgent: '来源 Agent',
+ 'agent.dsh': 'DeepSeek Harness',
+ 'agent.codex': 'Codex',
+ 'agent.claude': 'Claude Code',
+ 'agent.zcode': 'ZCode',
+ readingSources: '正在检测已安装的 Agent…',
+ noAgents: '未检测到已安装的 Agent。',
import: '导入',
importing: '正在导入…',
emptyResults: '来源目录中没有会话。',
@@ -34,9 +38,13 @@ export type ImportSessionsLocaleKey = keyof typeof zh
/** English dictionary checked against the Chinese key set. */
export const en = {
nav: 'Session management',
- sourceRoot: 'Source directory',
- sourceRootHint: 'The official DeepSeek Harness sessions directory',
- readingSourceRoot: 'Reading the source directory…',
+ sourceAgent: 'Source Agent',
+ 'agent.dsh': 'DeepSeek Harness',
+ 'agent.codex': 'Codex',
+ 'agent.claude': 'Claude Code',
+ 'agent.zcode': 'ZCode',
+ readingSources: 'Detecting installed Agents…',
+ noAgents: 'No installed Agent detected.',
import: 'Import',
importing: 'Importing…',
emptyResults: 'The source directory holds no sessions.',
diff --git a/packages/client/ui-settings-import-sessions/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-import-sessions/tests/browser-plugin.client.spec.tsx
index e0160b446671..a79474cbf992 100644
--- a/packages/client/ui-settings-import-sessions/tests/browser-plugin.client.spec.tsx
+++ b/packages/client/ui-settings-import-sessions/tests/browser-plugin.client.spec.tsx
@@ -20,16 +20,18 @@ async function bench() {
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
- const sourceRoot = vi.fn(async () => '/source/sessions')
+ const sessionSources = vi.fn(async () => ({
+ sources: [{ agent: 'dsh' as const, sourceRoot: '/source/sessions' }],
+ }))
const importSessions = vi.fn(async (): Promise
=> ({
sourceRoot: '/source/sessions',
results: [],
}))
const exportSession = vi.fn(async () => '# Export')
const archiveSession = vi.fn(async () => {})
- ctx.provide('harnessSessionsImport', { sourceRoot, importSessions, exportSession })
+ ctx.provide('harnessSessionsImport', { sessionSources, importSessions, exportSession })
ctx.provide('uiWorkspace', { archiveSession })
- return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, sourceRoot, importSessions, exportSession, archiveSession }
+ return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, sessionSources, importSessions, exportSession, archiveSession }
}
function declare(slots: SlotRegistry): () => void {
@@ -63,10 +65,12 @@ describe('ui-settings-import-sessions browser plugin', () => {
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Session management')
const injected = (entry.inject as unknown as () => ImportSessionsSectionInjected)()
- await expect(injected.sourceRoot()).resolves.toBe('/source/sessions')
+ await expect(injected.sessionSources()).resolves.toEqual({
+ sources: [{ agent: 'dsh', sourceRoot: '/source/sessions' }],
+ })
await expect(injected.importSessions({ sourceRoot: '/other/sessions' }))
.resolves.toMatchObject({ sourceRoot: '/source/sessions' })
- expect(b.sourceRoot).toHaveBeenCalled()
+ expect(b.sessionSources).toHaveBeenCalled()
expect(b.importSessions).toHaveBeenCalledWith({ sourceRoot: '/other/sessions' })
await b.ctx.fiber.dispose()
})
diff --git a/packages/client/ui-settings-import-sessions/tests/components.client.spec.tsx b/packages/client/ui-settings-import-sessions/tests/components.client.spec.tsx
index ed0c14f2734c..30d33c8457f1 100644
--- a/packages/client/ui-settings-import-sessions/tests/components.client.spec.tsx
+++ b/packages/client/ui-settings-import-sessions/tests/components.client.spec.tsx
@@ -2,7 +2,11 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionListState, SessionSummary } from '@mutantcat/dsh-api-session-controller/client'
-import type { SessionImportHarnessValue } from '@mutantcat/dsh-api-session-controller/types'
+import type {
+ SessionImportHarnessAgentSource,
+ SessionImportHarnessValue,
+ SessionImportHarnessSourcesValue,
+} from '@mutantcat/dsh-api-session-controller/types'
import type { SessionId } from '@mutantcat/dsh-session/types'
import { ImportSessionsSection } from '../src/client/ImportSessionsSection.tsx'
import type { ImportSessionsSectionProps } from '../src/client/ImportSessionsSection.tsx'
@@ -38,17 +42,18 @@ function value(results: SessionImportHarnessValue['results']): SessionImportHarn
}
function props(options: {
- configured?: string
- sourceRoot?: () => Promise
+ sources?: readonly SessionImportHarnessAgentSource[]
+ sessionSources?: ImportSessionsSectionProps['sessionSources']
importSessions?: ImportSessionsSectionProps['importSessions']
exportSession?: ImportSessionsSectionProps['exportSession']
archiveSession?: ImportSessionsSectionProps['archiveSession']
sessions?: SessionListState
useSessions?: ImportSessionsSectionProps['useSessions']
} = {}): ImportSessionsSectionProps {
+ const sources = options.sources ?? [{ agent: 'dsh' as const, sourceRoot: '/source/sessions' }]
return {
t,
- sourceRoot: options.sourceRoot ?? (async () => options.configured ?? '/source/sessions'),
+ sessionSources: options.sessionSources ?? (async () => ({ sources })),
importSessions: options.importSessions ?? (async () => value([])),
exportSession: options.exportSession ?? (async () => {}),
archiveSession: options.archiveSession ?? (async () => {}),
@@ -58,18 +63,26 @@ function props(options: {
}
describe('ImportSessionsSection', () => {
- it('prefills the configured source root and imports that root on request', async () => {
+ it('selects the first installed Agent and imports its sessions on request', async () => {
const importSessions = vi.fn(async () => value([
{ sourcePath: '/source/sessions/project/one', sessionId: sid('one'), status: 'imported' as const },
]))
- render()
+ render()
- const field = await screen.findByLabelText(en.sourceRoot) as HTMLInputElement
- await waitFor(() => { expect(field.value).toBe('/configured/sessions') })
+ const picker = await screen.findByLabelText(en.sourceAgent) as HTMLSelectElement
+ await waitFor(() => { expect(picker.value).toBe('/configured/sessions') })
fireEvent.click(screen.getByRole('button', { name: en.import }))
await waitFor(() => { expect(importSessions).toHaveBeenCalledWith({ sourceRoot: '/configured/sessions' }) })
+ expect(screen.getByRole('option', { name: en['agent.dsh'] })).toBeTruthy()
+ expect(screen.getByRole('option', { name: en['agent.codex'] })).toBeTruthy()
expect(await screen.findByText(en['status.imported'])).toBeTruthy()
expect(screen.getByText(en.resultSession.replace('{id}', 'one'))).toBeTruthy()
expect(screen.getByText(en.summary
@@ -77,18 +90,20 @@ describe('ImportSessionsSection', () => {
.replace('{alreadyPresent}', '0').replace('{refused}', '0'))).toBeTruthy()
})
- it('imports the root the user edits and omits an emptied field', async () => {
+ it('imports the Agent the user picks', async () => {
const importSessions = vi.fn(async () => value([]))
- render()
-
- const field = await screen.findByLabelText(en.sourceRoot) as HTMLInputElement
- fireEvent.change(field, { target: { value: '/elsewhere/sessions' } })
- fireEvent.click(screen.getByRole('button', { name: en.import }))
- await waitFor(() => { expect(importSessions).toHaveBeenCalledWith({ sourceRoot: '/elsewhere/sessions' }) })
+ render()
- fireEvent.change(field, { target: { value: ' ' } })
+ const picker = await screen.findByLabelText(en.sourceAgent) as HTMLSelectElement
+ fireEvent.change(picker, { target: { value: '/home/.claude' } })
fireEvent.click(screen.getByRole('button', { name: en.import }))
- await waitFor(() => { expect(importSessions).toHaveBeenLastCalledWith({}) })
+ await waitFor(() => { expect(importSessions).toHaveBeenCalledWith({ sourceRoot: '/home/.claude' }) })
})
it('reports an empty source directory and a refusal reason', async () => {
@@ -99,7 +114,7 @@ describe('ImportSessionsSection', () => {
]),
})} />)
- await screen.findByLabelText(en.sourceRoot)
+ await screen.findByLabelText(en.sourceAgent)
fireEvent.click(screen.getByRole('button', { name: en.import }))
expect(await screen.findByText(en['status.refused'])).toBeTruthy()
@@ -112,17 +127,25 @@ describe('ImportSessionsSection', () => {
it('shows the empty-source message when the scan finds nothing', async () => {
render()
- await screen.findByLabelText(en.sourceRoot)
+ await screen.findByLabelText(en.sourceAgent)
fireEvent.click(screen.getByRole('button', { name: en.import }))
expect(await screen.findByText(en.emptyResults)).toBeTruthy()
})
+ it('offers no import and names the cause when no Agent is installed', async () => {
+ render()
+
+ expect(await screen.findByText(en.noAgents)).toBeTruthy()
+ expect(screen.queryByText(en.readingSources)).toBeNull()
+ expect(screen.getByRole('button', { name: en.import }).hasAttribute('disabled')).toBe(true)
+ })
+
it('surfaces a failed call and stays usable', async () => {
const importSessions = vi.fn(async () => { throw new Error('gateway unavailable') })
render()
- await screen.findByLabelText(en.sourceRoot)
+ await screen.findByLabelText(en.sourceAgent)
fireEvent.click(screen.getByRole('button', { name: en.import }))
expect(await screen.findByText(en.failed.replace('{message}', 'gateway unavailable'))).toBeTruthy()
@@ -134,41 +157,46 @@ describe('ImportSessionsSection', () => {
importSessions: async () => { throw 'gateway offline' },
})} />)
- await screen.findByLabelText(en.sourceRoot)
+ await screen.findByLabelText(en.sourceAgent)
fireEvent.click(screen.getByRole('button', { name: en.import }))
expect(await screen.findByText(en.failed.replace('{message}', 'gateway offline'))).toBeTruthy()
})
- it('ignores a configured-root read that settles after the page unmounts', async () => {
- const settles: Array<{ done: (value: string) => void; fail: (reason: unknown) => void }> = []
- const pending = (): Promise => new Promise((done, fail) => {
- settles.push({ done, fail })
- })
- const resolved = render()
- const rejected = render()
+ it('ignores a discovery read that settles after the page unmounts', async () => {
+ const settles: Array<{
+ done: (value: SessionImportHarnessSourcesValue) => void
+ fail: (reason: unknown) => void
+ }> = []
+ const pending = (): Promise =>
+ new Promise((done, fail) => {
+ settles.push({ done, fail })
+ })
+ const resolved = render()
+ const rejected = render()
resolved.unmount()
rejected.unmount()
- settles[0]!.done('/configured/sessions')
+ settles[0]!.done({ sources: [{ agent: 'dsh', sourceRoot: '/configured/sessions' }] })
settles[1]!.fail(new Error('remote session unavailable'))
await new Promise(resolve => setTimeout(resolve, 0))
- expect(screen.queryByLabelText(en.sourceRoot)).toBeNull()
+ expect(screen.queryByLabelText(en.sourceAgent)).toBeNull()
})
- it('surfaces a failed configured-root read and lets the user type one instead', async () => {
+ it('surfaces a failed discovery read and offers no import', async () => {
const importSessions = vi.fn(async () => value([]))
render( { throw new Error('remote session unavailable') },
+ sessionSources: async () => { throw new Error('remote session unavailable') },
importSessions,
})} />)
expect(await screen.findByText(en.failed.replace('{message}', 'remote session unavailable'))).toBeTruthy()
- const field = screen.getByLabelText(en.sourceRoot) as HTMLInputElement
- fireEvent.change(field, { target: { value: '/typed/sessions' } })
- fireEvent.click(screen.getByRole('button', { name: en.import }))
- await waitFor(() => { expect(importSessions).toHaveBeenCalledWith({ sourceRoot: '/typed/sessions' }) })
+ // Discovery named no Agent, so neither the picker nor the import is offered.
+ const picker = screen.getByLabelText(en.sourceAgent) as HTMLSelectElement
+ expect(picker.hasAttribute('disabled')).toBe(true)
+ expect(screen.getByRole('button', { name: en.import }).hasAttribute('disabled')).toBe(true)
+ expect(importSessions).not.toHaveBeenCalled()
})
it('exports a listed session with its id and title', async () => {
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 0cf200d7c51a..13a3ebc155e3 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1818,6 +1818,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [],
returns: 'the configured foreign sessions root.',
},
+ {
+ signature: '@Remote(\'harnessSessionSources\') harnessSessionSources(): Promise',
+ description: 'List the Agent session stores installed on the Host machine, so a picker offers only what exists. Discovery reads directory presence only; no Session is opened.',
+ parameters: [],
+ returns: 'one entry per installed Agent, plus the configured root.',
+ },
{
signature: '@Remote(\'importHarnessSessions\') importHarnessSessions( request: SessionImportHarnessRequest, signal: AbortSignal, ): Promise',
description: 'Import the official DeepSeek Harness chat history into Alpha\'s own store. Read-only over the source root, idempotent, and isolated per Session; it never watches or synchronizes on its own.',
@@ -6161,6 +6167,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionId',
declaration: 'export type SessionId = Branded<\'SessionId\'>;',
},
+ {
+ name: 'SessionImportHarnessAgent',
+ declaration: 'export type SessionImportHarnessAgent = \'dsh\' | \'codex\' | \'claude\' | \'zcode\';',
+ },
+ {
+ name: 'SessionImportHarnessAgentSource',
+ declaration: 'export interface SessionImportHarnessAgentSource {\n readonly agent: SessionImportHarnessAgent;\n readonly sourceRoot: string;\n}',
+ },
{
name: 'SessionImportHarnessRequest',
declaration: 'export interface SessionImportHarnessRequest {\n readonly sourceRoot?: string;\n}',
@@ -6169,6 +6183,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionImportHarnessResult',
declaration: 'export interface SessionImportHarnessResult {\n readonly sourcePath: string;\n readonly sessionId?: SessionId;\n readonly status: SessionImportHarnessStatus;\n readonly reason?: string;\n}',
},
+ {
+ name: 'SessionImportHarnessSourcesValue',
+ declaration: 'export interface SessionImportHarnessSourcesValue {\n readonly sources: readonly SessionImportHarnessAgentSource[];\n}',
+ },
{
name: 'SessionImportHarnessSourceValue',
declaration: 'export interface SessionImportHarnessSourceValue {\n readonly sourceRoot: string;\n}',
diff --git a/packages/test-support/client-runtime/tests/assembly-session-import.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-session-import.client.spec.ts
index f69ec18d7e3b..468cb23f905e 100644
--- a/packages/test-support/client-runtime/tests/assembly-session-import.client.spec.ts
+++ b/packages/test-support/client-runtime/tests/assembly-session-import.client.spec.ts
@@ -23,9 +23,10 @@ const CONNECTION_PACKAGE = '@mutantcat/dsh-client-connection'
const IMPORT_SESSIONS_PACKAGE = '@mutantcat/dsh-client-ui-settings-import-sessions'
describe('session-management import face over the mounted namespaces', () => {
- it('answers source-root, import, and export reads from a consumer fiber', async () => {
+ it('answers source-root, source-list, import, and export reads from a consumer fiber', async () => {
const mock = RemoteMock.create().load(remoteDefaultResponses)
.unary('session/harnessSessionsRoot', ok({ sourceRoot: '/source/sessions' }))
+ .unary('session/harnessSessionSources', ok({ sources: [{ agent: 'dsh', sourceRoot: '/source/sessions' }] }))
.unary('session/importHarnessSessions', ok({ sourceRoot: '/source/sessions', results: [] }))
.unary('session/exportSessionMarkdown', ok({ markdown: '# Transcript' }))
const roster = webApp.closure([IMPORT_SESSIONS_PACKAGE])
@@ -46,6 +47,7 @@ describe('session-management import face over the mounted namespaces', () => {
interface PendingReads {
sourceRoot: Promise
+ sources: Promise
imported: Promise
exported: Promise
}
@@ -55,6 +57,7 @@ describe('session-management import face over the mounted namespaces', () => {
apply: (consumerCtx) => {
pending = {
sourceRoot: consumerCtx.harnessSessionsImport.sourceRoot(),
+ sources: consumerCtx.harnessSessionsImport.sessionSources(),
imported: consumerCtx.harnessSessionsImport.importSessions({ sourceRoot: '/source/sessions' }),
exported: consumerCtx.harnessSessionsImport.exportSession('session-1'),
}
@@ -63,6 +66,7 @@ describe('session-management import face over the mounted namespaces', () => {
expect(pending).toBeDefined()
await expect(pending!.sourceRoot).resolves.toBe('/source/sessions')
+ await expect(pending!.sources).resolves.toEqual({ sources: [{ agent: 'dsh', sourceRoot: '/source/sessions' }] })
await expect(pending!.imported).resolves.toEqual({ sourceRoot: '/source/sessions', results: [] })
await expect(pending!.exported).resolves.toBe('# Transcript')
})
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 51c96d646d9c..940f2e569be8 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -388,6 +388,7 @@ export const LINK_MAP: Readonly> = {
SessionImportHarnessRequest: 'session.md',
SessionImportHarnessValue: 'session.md',
SessionImportHarnessSourceValue: 'session.md',
+ SessionImportHarnessSourcesValue: 'session.md',
SessionModels: 'session.md',
SessionModelsRequest: 'session.md',
SessionPage: 'session.md',