DeepSeek Harness previously treated every selected model as text-only. It now carries the model profile's image capability through Main, the Utility Host, ACP, and Pi-AI, rejects unsupported images before model invocation, and validates supported JPEG/PNG data in a bounded process-local attachment store. The Composer keeps universal controls on its first row, places OpenCode and Continue controls on a dedicated responsive row, and anchors context compaction without shifting the footer. Runtime supervision ownership for future Subagents and Jobs is documented for the right sidebar. Release note: DeepSeek Harness 现可按所选模型连接的能力安全接收 JPEG/PNG 图片;Composer 同时将 Runtime 专属选择器移到独立一行,并固定上下文压缩入口。
425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
|
import type { BrowserToolService } from '../browser/browser-model-tools'
|
|
import { defaultRuntimeCustomizationSettings } from '../../shared/contracts'
|
|
import {
|
|
createAgentRuntime,
|
|
createModelProfileRuntime
|
|
} from './create-runtime'
|
|
import { AgentRuntimeController } from './runtime-controller'
|
|
|
|
function createBrowserService(): BrowserToolService & {
|
|
dispose: ReturnType<typeof vi.fn>
|
|
} {
|
|
return {
|
|
getOrigin: vi.fn(() => undefined),
|
|
navigate: vi.fn(),
|
|
snapshot: vi.fn(),
|
|
click: vi.fn(),
|
|
type: vi.fn(),
|
|
select: vi.fn(),
|
|
back: vi.fn(),
|
|
screenshot: vi.fn(),
|
|
releaseConversation: vi.fn(async () => undefined),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
}
|
|
|
|
function settings(
|
|
overrides: Partial<ResolvedRuntimeSettings> = {}
|
|
): ResolvedRuntimeSettings {
|
|
const defaultModelProfileId =
|
|
'00000000-0000-4000-8000-000000000001'
|
|
return {
|
|
provider: 'model',
|
|
modelBaseUrl: 'http://127.0.0.1:11434/v1',
|
|
modelName: 'qwen3',
|
|
modelProtocol: 'openai-chat-completions',
|
|
modelAuthentication: 'none',
|
|
imageGenerationQuality: 'auto',
|
|
modelProfiles: [
|
|
{
|
|
id: defaultModelProfileId,
|
|
name: '默认模型',
|
|
baseUrl: 'http://127.0.0.1:11434/v1',
|
|
modelName: 'qwen3',
|
|
protocol: 'openai-chat-completions',
|
|
authentication: 'none',
|
|
imageGenerationQuality: 'auto'
|
|
}
|
|
],
|
|
defaultModelProfileId,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: false,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat',
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl:
|
|
'http://127.0.0.1:11434/v1/embeddings',
|
|
knowledgeEmbeddingModel: 'nomic-embed-text',
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
|
|
knowledgeRerankModel: 'rerank-v3.5',
|
|
runtimeCustomization: defaultRuntimeCustomizationSettings,
|
|
workspacePath: process.cwd(),
|
|
toolApproval: 'always',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('createAgentRuntime model compatibility', () => {
|
|
it('does not treat the default model profile as the platform DeepSeek source', () => {
|
|
const defaultProfile = {
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: 'Default DeepSeek',
|
|
baseUrl: 'https://api.deepseek.com',
|
|
modelName: 'deepseek-chat',
|
|
protocol: 'openai-chat-completions' as const,
|
|
authentication: 'api-key' as const,
|
|
imageGenerationQuality: 'auto' as const,
|
|
apiKey: 'default-deepseek-key'
|
|
}
|
|
|
|
expect(() =>
|
|
createAgentRuntime(
|
|
process.cwd(),
|
|
settings({
|
|
provider: 'deepseek-harness',
|
|
modelBaseUrl: defaultProfile.baseUrl,
|
|
modelName: defaultProfile.modelName,
|
|
modelProtocol: defaultProfile.protocol,
|
|
modelAuthentication: defaultProfile.authentication,
|
|
apiKey: defaultProfile.apiKey,
|
|
modelProfiles: [defaultProfile]
|
|
}),
|
|
{ deepseekHarnessLauncher: vi.fn() }
|
|
)
|
|
).toThrow(
|
|
'DeepSeek Harness 需要使用 API Key 的安全 OpenAI 兼容 Chat Completions 模型连接'
|
|
)
|
|
})
|
|
|
|
it('forwards a compatible gateway profile to DeepSeek Harness', async () => {
|
|
const profile = {
|
|
id: '00000000-0000-4000-8000-000000000006',
|
|
name: 'OpenAI-compatible gateway',
|
|
baseUrl: 'https://gateway.example/openai/v1',
|
|
modelName: 'qwen-plus',
|
|
protocol: 'openai-chat-completions' as const,
|
|
authentication: 'api-key' as const,
|
|
supportsImageInput: true,
|
|
imageGenerationQuality: 'auto' as const,
|
|
apiKey: 'gateway-key'
|
|
}
|
|
const deepseekHarnessLauncher = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error('stop after launch options'))
|
|
const runtime = createAgentRuntime(
|
|
process.cwd(),
|
|
settings({
|
|
provider: 'deepseek-harness',
|
|
modelProfiles: [profile],
|
|
defaultModelProfileId: profile.id,
|
|
deepseekHarnessModelProfile: profile
|
|
}),
|
|
{ deepseekHarnessLauncher }
|
|
)
|
|
|
|
expect(runtime.runtimeId).toBe('deepseek-harness')
|
|
await expect(runtime.getStatus()).resolves.toMatchObject({
|
|
available: false
|
|
})
|
|
expect(deepseekHarnessLauncher).toHaveBeenCalledWith(
|
|
expect.objectContaining({ supportsImageInput: true })
|
|
)
|
|
await runtime.dispose()
|
|
})
|
|
|
|
it('creates an available direct runtime for a no-auth model', async () => {
|
|
const runtime = createAgentRuntime(process.cwd(), settings())
|
|
|
|
await expect(runtime.getStatus()).resolves.toMatchObject({
|
|
id: 'model',
|
|
available: true,
|
|
supportsToolExecution: true,
|
|
detail: expect.stringContaining('OpenAI Chat Completions')
|
|
})
|
|
await runtime.dispose()
|
|
})
|
|
|
|
it('forwards the selected profile image capability to direct runtimes', async () => {
|
|
const visionSettings = settings({
|
|
supportsImageInput: true
|
|
})
|
|
visionSettings.modelProfiles = visionSettings.modelProfiles.map(
|
|
(profile) => ({
|
|
...profile,
|
|
supportsImageInput: true
|
|
})
|
|
)
|
|
const fetcher = vi.fn(async () =>
|
|
new Response(
|
|
[
|
|
`data: ${JSON.stringify({
|
|
choices: [
|
|
{
|
|
delta: { content: 'OK' },
|
|
finish_reason: 'stop'
|
|
}
|
|
]
|
|
})}`,
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
''
|
|
].join('\n'),
|
|
{
|
|
status: 200,
|
|
headers: { 'content-type': 'text/event-stream' }
|
|
}
|
|
)
|
|
)
|
|
vi.stubGlobal('fetch', fetcher)
|
|
const runtime = createAgentRuntime(process.cwd(), visionSettings)
|
|
|
|
try {
|
|
const events = []
|
|
for await (const event of runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'wechat-conversation',
|
|
prompt: '描述图片',
|
|
images: [
|
|
{
|
|
name: '微信图片.png',
|
|
mediaType: 'image/png',
|
|
data: 'aW1hZ2U='
|
|
}
|
|
]
|
|
},
|
|
new AbortController().signal
|
|
)) {
|
|
events.push(event)
|
|
}
|
|
|
|
expect(fetcher).toHaveBeenCalledOnce()
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({ type: 'done' })
|
|
)
|
|
} finally {
|
|
await runtime.dispose()
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
it('shares injected browser service without runtime-owned disposal', async () => {
|
|
const browserService = createBrowserService()
|
|
const first = createAgentRuntime(process.cwd(), settings(), {
|
|
browserService
|
|
})
|
|
const second = createAgentRuntime(process.cwd(), settings(), {
|
|
browserService
|
|
})
|
|
const controller = new AgentRuntimeController(first)
|
|
|
|
await controller.releaseConversation('conversation-one')
|
|
await controller.replace(second)
|
|
await controller.releaseConversation('conversation-two')
|
|
await controller.dispose()
|
|
|
|
expect(browserService.releaseConversation).toHaveBeenNthCalledWith(
|
|
1,
|
|
'conversation-one'
|
|
)
|
|
expect(browserService.releaseConversation).toHaveBeenNthCalledWith(
|
|
2,
|
|
'conversation-two'
|
|
)
|
|
expect(browserService.dispose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not expose the browser service to OpenCode runtimes', async () => {
|
|
const browserService = createBrowserService()
|
|
const runtime = createAgentRuntime(
|
|
process.cwd(),
|
|
settings({
|
|
provider: 'opencode',
|
|
opencodeBaseUrl: 'http://127.0.0.1:4096'
|
|
}),
|
|
{ browserService }
|
|
)
|
|
|
|
await runtime.releaseConversation?.('opencode-conversation')
|
|
await runtime.dispose()
|
|
|
|
expect(browserService.releaseConversation).not.toHaveBeenCalled()
|
|
expect(browserService.dispose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it(
|
|
'treats a blank OpenCode Server as bundled local mode even for legacy false settings',
|
|
async () => {
|
|
const runtime = createAgentRuntime(
|
|
process.cwd(),
|
|
settings({
|
|
provider: 'opencode',
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: false
|
|
})
|
|
)
|
|
|
|
await expect(runtime.getStatus()).resolves.not.toMatchObject({
|
|
detail: '未配置 OpenCode Server'
|
|
})
|
|
await runtime.dispose()
|
|
},
|
|
15_000
|
|
)
|
|
|
|
it.each([
|
|
['openai-chat-completions', 'none'],
|
|
['openai-responses', 'api-key']
|
|
] as const)(
|
|
'accepts an OpenCode %s independent profile',
|
|
async (protocol, authentication) => {
|
|
const runtime = createAgentRuntime(
|
|
process.cwd(),
|
|
settings({
|
|
provider: 'opencode',
|
|
opencodeModelProfile: {
|
|
id: '00000000-0000-4000-8000-000000000031',
|
|
name: 'OpenAI profile',
|
|
baseUrl: 'https://api.example/v1',
|
|
modelName: 'model',
|
|
protocol,
|
|
authentication,
|
|
imageGenerationQuality: 'auto',
|
|
...(authentication === 'api-key'
|
|
? { apiKey: 'secret' }
|
|
: {})
|
|
}
|
|
})
|
|
)
|
|
|
|
expect(runtime.requiresToolApproval).toBe(false)
|
|
await runtime.dispose()
|
|
}
|
|
)
|
|
|
|
it('marks direct image runtimes and rejects them for Agent Runtimes', async () => {
|
|
const imageSettings = settings({
|
|
modelBaseUrl: 'https://bigtoken.ai/v1',
|
|
modelName: 'gpt-image-2',
|
|
modelProtocol: 'openai-images-generations',
|
|
modelAuthentication: 'api-key',
|
|
imageGenerationQuality: 'high',
|
|
apiKey: 'secret',
|
|
modelProfiles: [
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: '默认图像模型',
|
|
baseUrl: 'https://bigtoken.ai/v1',
|
|
modelName: 'gpt-image-2',
|
|
protocol: 'openai-images-generations',
|
|
authentication: 'api-key',
|
|
imageGenerationQuality: 'high',
|
|
apiKey: 'secret'
|
|
}
|
|
]
|
|
})
|
|
const runtime = createAgentRuntime(process.cwd(), imageSettings)
|
|
await expect(runtime.getStatus()).resolves.toMatchObject({
|
|
capability: 'image-generation'
|
|
})
|
|
await runtime.dispose()
|
|
|
|
expect(() =>
|
|
createAgentRuntime(
|
|
process.cwd(),
|
|
settings({
|
|
provider: 'continue',
|
|
continueModelProfile: {
|
|
id: '00000000-0000-4000-8000-000000000032',
|
|
name: 'Image profile',
|
|
baseUrl: 'https://bigtoken.ai/v1',
|
|
modelName: 'gpt-image-2',
|
|
protocol: 'openai-images-generations',
|
|
authentication: 'api-key',
|
|
imageGenerationQuality: 'high',
|
|
apiKey: 'secret'
|
|
}
|
|
})
|
|
)
|
|
).toThrow('Continue 独立模型连接仅支持')
|
|
expect(() =>
|
|
createAgentRuntime(
|
|
process.cwd(),
|
|
settings({
|
|
provider: 'opencode',
|
|
opencodeModelProfile: {
|
|
id: '00000000-0000-4000-8000-000000000033',
|
|
name: 'Image profile',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
modelName: 'gpt-image-2',
|
|
protocol: 'openai-images-generations',
|
|
authentication: 'api-key',
|
|
imageGenerationQuality: 'auto',
|
|
apiKey: 'secret'
|
|
}
|
|
})
|
|
)
|
|
).toThrow('OpenCode 独立模型连接仅支持')
|
|
})
|
|
|
|
it('accepts a Continue Responses independent profile', async () => {
|
|
const runtime = createAgentRuntime(
|
|
process.cwd(),
|
|
settings({
|
|
provider: 'continue',
|
|
continueModelProfile: {
|
|
id: '00000000-0000-4000-8000-000000000035',
|
|
name: 'Responses profile',
|
|
baseUrl: 'https://api.example/v1',
|
|
modelName: 'gpt-compatible',
|
|
protocol: 'openai-responses',
|
|
authentication: 'api-key',
|
|
imageGenerationQuality: 'auto',
|
|
apiKey: 'secret'
|
|
}
|
|
})
|
|
)
|
|
|
|
expect(runtime.requiresToolApproval).toBe(false)
|
|
await runtime.dispose()
|
|
})
|
|
|
|
it('creates a testable runtime for an image model profile', async () => {
|
|
const resolved = settings()
|
|
const runtime = createModelProfileRuntime(
|
|
process.cwd(),
|
|
resolved,
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000034',
|
|
name: 'Image profile',
|
|
baseUrl: 'https://bigtoken.ai/v1',
|
|
modelName: 'gpt-image-2',
|
|
protocol: 'openai-images-generations',
|
|
authentication: 'api-key',
|
|
imageGenerationQuality: 'high',
|
|
apiKey: 'secret'
|
|
}
|
|
)
|
|
|
|
await expect(runtime.getStatus()).resolves.toMatchObject({
|
|
id: 'model',
|
|
capability: 'image-generation',
|
|
available: true
|
|
})
|
|
await runtime.dispose()
|
|
})
|
|
})
|