Multiple projects could spawn duplicate heavyweight runtimes, while settings changes and remote reconnection could cancel or strand native conversations. Share compatible local OpenCode and DeepSeek Harness process owners and Agent-owned ACP connections, retaining workspace routing, per-session modes, model attribution, question ownership, targeted cancellation and idle cleanup. Restore remote pending questions and terminal task projections across reconnect and desktop restart, and preserve accepted ordinary requests during settings replacement while cancelling expert-team parents with their replaced scheduler. Extend regression coverage and Agent type checks. Record measured resource and real-model validation results, including failures and limitations. Also include the conversation media-generation design drafts (not an implemented feature), documentation entry links and dedicated Linux test-directory guidance. Desktop and Agent changes must be delivered together for shared process results. Validation: typecheck and lint passed; staged content and documentation links checked. Prior full regression and live-model evidence are recorded in progress notes; no new full test suite or model calls were run for this commit. Release note: 多项目会话现在复用兼容的 OpenCode 和 DeepSeek Harness 进程,减少重复启动并保持工作区、工具和取消隔离。修复远端问答在断线、重启及配置更新后的恢复与状态同步问题,Desktop 与 Agent 需配套更新。
546 lines
16 KiB
TypeScript
546 lines
16 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'
|
|
import type { WorkspaceAccess } from '../workspace'
|
|
|
|
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,
|
|
requestHeaders: { 'x-tenant-id': 'harness-tenant' },
|
|
requestBody: { temperature: 0.2 },
|
|
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: true
|
|
})
|
|
expect(deepseekHarnessLauncher).not.toHaveBeenCalled()
|
|
await expect(runtime.testConnection!()).resolves.toMatchObject({
|
|
available: false
|
|
})
|
|
expect(deepseekHarnessLauncher).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
supportsImageInput: true,
|
|
requestHeaders: { 'x-tenant-id': 'harness-tenant' }
|
|
})
|
|
)
|
|
expect(
|
|
deepseekHarnessLauncher.mock.calls[0]?.[0]
|
|
).not.toHaveProperty('requestBody')
|
|
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 request customization to direct chats', async () => {
|
|
const profile = {
|
|
id: '00000000-0000-4000-8000-000000000041',
|
|
name: 'Tenant model',
|
|
baseUrl: 'https://gateway.example/v1',
|
|
modelName: 'tenant-model',
|
|
protocol: 'openai-chat-completions' as const,
|
|
authentication: 'none' as const,
|
|
supportsImageInput: false,
|
|
maximumOutputTokens: 48_000,
|
|
imageGenerationQuality: 'auto' as const,
|
|
requestHeaders: {
|
|
'x-tenant-id': 'tenant-a'
|
|
},
|
|
requestBody: {
|
|
temperature: 0.2
|
|
}
|
|
}
|
|
const fetcher = vi.fn<typeof fetch>(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(),
|
|
settings({
|
|
modelProfiles: [profile],
|
|
defaultModelProfileId: profile.id
|
|
})
|
|
)
|
|
|
|
try {
|
|
for await (const _event of runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ee',
|
|
conversationId: 'customized-direct-chat',
|
|
prompt: 'hello'
|
|
},
|
|
new AbortController().signal
|
|
)) {
|
|
void _event
|
|
}
|
|
|
|
const init = fetcher.mock.calls[0]?.[1]
|
|
expect(new Headers(init?.headers).get('x-tenant-id')).toBe(
|
|
'tenant-a'
|
|
)
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
model: 'tenant-model',
|
|
temperature: 0.2
|
|
})
|
|
} finally {
|
|
await runtime.dispose()
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
it('rejects remote process runtimes before invoking any local launcher', () => {
|
|
const launch = vi.fn()
|
|
const launchEnvironmentProvider = vi.fn(() =>
|
|
Object.freeze({ PATH: '/local-tools:/usr/bin' })
|
|
)
|
|
const workspaceAccess = {
|
|
dispose: vi.fn(async () => undefined)
|
|
} as unknown as WorkspaceAccess
|
|
|
|
expect(() =>
|
|
createAgentRuntime(
|
|
'C:\\SafeLocalDefault',
|
|
settings({
|
|
provider: 'deepseek-harness'
|
|
}),
|
|
{
|
|
deepseekHarnessLauncher: launch,
|
|
launchEnvironmentProvider,
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: 'host-one',
|
|
remoteRootPath: '/srv/project',
|
|
cacheIdentity: 'remote-one',
|
|
routeIdentity: 'remote-one',
|
|
workspaceAccess
|
|
}
|
|
}
|
|
)
|
|
).toThrow('远程执行空间尚不可用')
|
|
expect(launch).not.toHaveBeenCalled()
|
|
expect(launchEnvironmentProvider).not.toHaveBeenCalled()
|
|
})
|
|
})
|