Files
deepseek-harness/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts
T
Yichen Jiang 61719629fc Merge remote-tracking branch 'origin/master' into worktree/process-plugin-streaming-72f9b3
# Conflicts:
#	apps/web/tests/expected/agent-preset-selection/header.expected.md
#	docs/architecture.i18n.yaml
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	docs/persistence-catalog.i18n.yaml
#	docs/persistence-catalog.md
#	docs/persistence-catalog.zh.md
#	docs/persistence-schema.json
#	docs/subsystems/web-client.i18n.yaml
#	packages/api/README.i18n.yaml
#	packages/api/README.md
#	packages/api/README.zh.md
#	packages/api/session-controller/README.i18n.yaml
#	packages/api/session-controller/README.md
#	packages/api/session-controller/README.zh.md
#	packages/api/session-controller/src/client/sessions/manager.ts
#	packages/api/session-controller/src/client/sessions/service.ts
#	packages/api/session-controller/tests/manager.client.spec.ts
#	packages/client/locale/tests/language-row.client.spec.tsx
#	packages/client/ui-chat/tests/chat-view.client.spec.tsx
#	packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
#	packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
#	packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.client.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.client.spec.tsx
#	packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
#	packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
#	packages/client/ui-layout/tests/app-frame.client.spec.tsx
#	packages/client/ui-layout/tests/document-title.client.spec.tsx
#	packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-session/tests/ui-session.client.spec.ts
#	packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
#	packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx
#	packages/client/ui-subagent/README.i18n.yaml
#	packages/client/ui-subagent/README.md
#	packages/client/ui-subagent/README.zh.md
#	packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
#	packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx
#	packages/client/ui-theme/tests/appearance-row.client.spec.tsx
#	packages/client/ui-theme/tests/font-size-row.client.spec.tsx
#	packages/client/ui-tool/tests/coverage-tails.client.spec.tsx
#	packages/client/ui-tool/tests/diff-card.client.spec.tsx
#	packages/client/ui-tool/tests/image-card.client.spec.tsx
#	packages/client/ui-tool/tests/read-card.client.spec.tsx
#	packages/client/ui-tool/tests/terminal-card.client.spec.tsx
#	packages/client/ui-trajectory/tests/views.client.spec.tsx
#	packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
#	packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
#	packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
#	packages/client/ui-workspace/tests/apply.client.spec.ts
#	packages/client/ui-workspace/tests/tree.client.spec.ts
#	packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
#	packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx
#	packages/client/ui-workspace/tests/workspaces-service.client.spec.ts
#	packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
#	packages/jobs/tool-jobs/src/index.ts
#	packages/shell/tool-bash/README.i18n.yaml
#	packages/test-support/client-runtime/src/sessions.ts
2026-09-21 11:34:20 +08:00

174 lines
7.4 KiB
TypeScript

/** ui-subagent browser half: catalog actions and read-only composer routing. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { describe, expect, it } from 'vitest'
import type {
SessionListState, SessionSnapshot, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import {
SubagentHeaderLineage, type SubagentCatalogInjected,
} from '../src/client/SubagentHeaderLineage.tsx'
import {
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
} from '../src/client/SubagentReadOnlyComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
function summary(partial: Partial<SessionSummary> & { id: SessionId }): SessionSummary {
return {
displayTitle: partial.id,
running: false,
retainedBy: {},
updatedAt: 0,
...partial,
} as SessionSummary
}
const sid = (id: string) => id as SessionId
/** Fake root sessions face for catalog actions. */
function sessionsWith(sessions: SessionSummary[]) {
const byId: Record<string, SessionSummary> = {}
for (const s of sessions) byId[s.id] = s
const snapshot: SessionListState = { ids: sessions.map(s => s.id), byId, phase: 'ready', projectionsBySession: {} }
const actionCalls: { method: string; args: unknown[] }[] = []
const address: SubagentAddress = {
parentSessionId: sid('parent'),
childSessionId: sid('c1'),
mode: 'continuable',
}
return {
list: {
getSnapshot: () => snapshot,
subscribe: () => () => {},
},
actionCalls,
subagentAddress: (childSessionId: SessionId) => childSessionId === address.childSessionId
? address
: undefined,
refreshProjections: (parentSessionId: SessionId) => {
actionCalls.push({ method: 'refreshProjections', args: [parentSessionId] })
return Promise.resolve()
},
}
}
async function provideSlotFaces(ctx: Context): Promise<void> {
await ctx.plugin(SlotRegistry).await()
ctx.slots.register({
name: 'root',
children: {
'conversation.session.header.lineage': { kind: 'single', scope: 'session' },
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
},
} as never, () => null)
}
/** Boot the plugin over fake sessions and slot faces. */
async function fullBench(sessions: SessionSummary[]) {
const ctx = new Context()
const face = sessionsWith(sessions)
ctx.provide('sessions', face)
ctx.provide('uiWorkspace', {
openSession: (address: SubagentAddress) => {
face.actionCalls.push({ method: 'openSession', args: [address] })
},
} as never)
ctx.provide('sidebarRight', {
openResource: (address: string, options: unknown) => {
face.actionCalls.push({ method: 'openResource', args: [address, options] })
},
} as never)
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
await provideSlotFaces(ctx)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
await ctx.plugin({ inject: [...inject], apply }).await()
return { face, ctx }
}
const FAMILY: SessionSummary[] = [
summary({ id: sid('parent'), displayTitle: 'parent', running: true }),
summary({ id: sid('c1'), parentId: sid('parent'), displayTitle: 'worker-1', running: true }),
summary({ id: sid('c2'), parentId: sid('parent'), displayTitle: 'worker-2', running: true }),
// Filtered out: not running / other parent / label miss.
summary({ id: sid('c3'), parentId: sid('parent'), displayTitle: 'worker-3', running: false }),
summary({ id: sid('c4'), parentId: sid('other'), displayTitle: 'worker-4', running: true }),
summary({ id: sid('c5'), parentId: sid('parent'), displayTitle: 'scout', running: true }),
]
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight'])
})
it('registers catalog actions and selects read-only subagent composers from session facts', async () => {
const { ctx, face } = await fullBench(FAMILY)
const catalogEntry = ctx.slots.entries('conversation.session.header.lineage')
.find(entry => entry.component === SubagentHeaderLineage)!
const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent'))
const address: SubagentAddress = {
parentSessionId: sid('parent'),
childSessionId: sid('c1'),
mode: 'continuable',
}
actions.openChild(address)
actions.openChildAside(address)
actions.refreshProjection(sid('parent'))
expect(face.actionCalls).toEqual([
{ method: 'openSession', args: [address] },
{
method: 'openResource',
args: [
'dsh-resource://subagentchat/session/c1?parent=parent&mode=continuable',
{ kind: 'subagentchat', preferNewPane: true },
],
},
{ method: 'refreshProjections', args: [sid('parent')] },
])
// The root-session catalog seat registers in the actions band with the
// same business face, ordered after the task list.
const actionEntry = ctx.slots.entries('conversation.session.header.actions')
.find(entry => entry.options.id === 'subagent-catalog')!
expect(actionEntry.options.order).toBe(30)
expect(actionEntry.inject).toBe(catalogEntry.inject)
const composerEntry = ctx.slots.entries('conversation.composer')
.find(entry => entry.component === SubagentReadOnlyComposer)!
const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null
const owner = (
subagent: SessionSnapshot['subagent'] | undefined,
running = false,
): ComposerChainProps => ({
sessionId: subagent?.address.childSessionId,
session: subagent === undefined
? undefined
: ({ subagent, running } as SessionSnapshot),
pendingInteraction: undefined,
})
expect(select(owner(undefined))).toBeNull()
expect(select(owner(null))).toBeNull()
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true })))
.toEqual({ reason: 'one-shot' })
// One-shot stays read-only even while running: it has no stop action.
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true)))
.toEqual({ reason: 'one-shot' })
expect(select(owner({ address: { ...address, mode: 'unknown' }, parentAvailable: true })))
.toEqual({ reason: 'unknown' })
expect(select(owner({ address }))).toBeNull()
expect(select(owner({ address, parentAvailable: true }))).toBeNull()
expect(select(owner({ address, parentAvailable: false })))
.toEqual({ reason: 'parent-unavailable' })
// A RUNNING parent-offline continuable yields the default composer, whose
// disabled input still carries the primary Stop; stopped, it takes back over.
expect(select(owner({ address, parentAvailable: false }, true))).toBeNull()
})
})