639 lines
18 KiB
TypeScript
639 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { RuntimeEvent } from './runtime'
|
|
import { ContinueHostRunError } from './continue-host-adapter'
|
|
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
detectRuntimeBinary: vi.fn(),
|
|
runHost: vi.fn(),
|
|
disposeHost: vi.fn(),
|
|
prepareHost: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./runtime-discovery', () => ({
|
|
detectRuntimeBinary: mocks.detectRuntimeBinary
|
|
}))
|
|
|
|
import { ContinueAgentRuntime } from './continue-runtime'
|
|
|
|
function createRuntime(): ContinueAgentRuntime {
|
|
return new ContinueAgentRuntime({
|
|
binaryPath: '',
|
|
configPath: 'C:\\safe config\\continue.yaml',
|
|
defaultWorkspace: process.cwd(),
|
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
|
createHostAdapter: () => ({
|
|
getPreparedHost: mocks.prepareHost,
|
|
run: mocks.runHost,
|
|
dispose: mocks.disposeHost
|
|
})
|
|
})
|
|
}
|
|
|
|
async function collectEvents(
|
|
runtime: ContinueAgentRuntime,
|
|
workMode?: 'ask' | 'plan' | 'execute'
|
|
): Promise<RuntimeEvent[]> {
|
|
const events: RuntimeEvent[] = []
|
|
for await (const event of runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'test',
|
|
workMode
|
|
},
|
|
new AbortController().signal
|
|
)) {
|
|
events.push(event)
|
|
}
|
|
return events
|
|
}
|
|
|
|
describe('ContinueAgentRuntime', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mocks.detectRuntimeBinary.mockResolvedValue({
|
|
available: true,
|
|
path: 'C:\\canonical\\cn.cmd',
|
|
version: '1.5.47',
|
|
detail: 'Continue CLI 1.5.47 已就绪'
|
|
})
|
|
mocks.prepareHost.mockResolvedValue({
|
|
entryPath: 'C:\\safe\\continue-host\\dist\\cn.js',
|
|
version: '1.5.47'
|
|
})
|
|
mocks.runHost.mockResolvedValue({
|
|
text: 'Continue response'
|
|
})
|
|
})
|
|
|
|
it('does not launch the CLI for an already-cancelled request', async () => {
|
|
const runtime = createRuntime()
|
|
const controller = new AbortController()
|
|
controller.abort(new Error('cancelled'))
|
|
const stream = runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'test',
|
|
workMode: 'execute'
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
await expect(stream.next()).rejects.toThrow('cancelled')
|
|
expect(mocks.detectRuntimeBinary).not.toHaveBeenCalled()
|
|
expect(mocks.runHost).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the resolved binary through the Continue host adapter', async () => {
|
|
const runtime = createRuntime()
|
|
|
|
const events = await collectEvents(runtime)
|
|
|
|
expect(mocks.detectRuntimeBinary).toHaveBeenCalledWith({
|
|
binaryPath: '',
|
|
bundledPath: undefined,
|
|
bundledValidation: 'canonical-file',
|
|
binaryNames: ['cn'],
|
|
label: 'Continue CLI'
|
|
})
|
|
expect(mocks.runHost).toHaveBeenCalledWith(
|
|
'test',
|
|
expect.any(AbortSignal),
|
|
expect.any(Function),
|
|
expect.objectContaining({
|
|
onEvent: expect.any(Function)
|
|
})
|
|
)
|
|
expect(events).toContainEqual({
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
type: 'text',
|
|
delta: 'Continue response'
|
|
})
|
|
expect(events).not.toContainEqual(
|
|
expect.objectContaining({ type: 'model-usage' })
|
|
)
|
|
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
|
})
|
|
|
|
it('emits one request-scoped host usage event at the end', async () => {
|
|
mocks.runHost.mockResolvedValue({
|
|
text: 'Continue response',
|
|
usage: {
|
|
provider: 'openai',
|
|
model: 'qwen3',
|
|
inputTokens: 31,
|
|
outputTokens: 9,
|
|
cacheReadTokens: 13,
|
|
cacheWriteTokens: 4
|
|
}
|
|
})
|
|
|
|
const events = await collectEvents(createRuntime(), 'execute')
|
|
|
|
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
type: 'model-usage',
|
|
callId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
runtime: 'continue',
|
|
provider: 'openai',
|
|
model: 'qwen3',
|
|
inputTokens: 31,
|
|
outputTokens: 9,
|
|
cacheReadTokens: 13,
|
|
cacheWriteTokens: 4
|
|
}
|
|
])
|
|
expect(events.at(-2)).toMatchObject({ type: 'model-usage' })
|
|
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
|
})
|
|
|
|
it('does not require whole-run approval', () => {
|
|
const runtime = createRuntime()
|
|
expect(runtime.requiresToolApproval).toBe(false)
|
|
})
|
|
|
|
it('passes scoped MCP configuration for Ask and denies every other Ask tool', async () => {
|
|
const runtime = new ContinueAgentRuntime({
|
|
binaryPath: '',
|
|
configPath: 'C:\\safe config\\continue.yaml',
|
|
defaultWorkspace: process.cwd(),
|
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
|
knowledgeGateway: {
|
|
getEndpoint: () => 'http://127.0.0.1:4567/mcp'
|
|
} as unknown as KnowledgeMcpGateway,
|
|
createHostAdapter: () => ({
|
|
getPreparedHost: mocks.prepareHost,
|
|
run: mocks.runHost,
|
|
dispose: mocks.disposeHost
|
|
})
|
|
})
|
|
for await (const _event of runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'search',
|
|
workMode: 'ask',
|
|
knowledgeCapabilityToken: 'main-only-token'
|
|
},
|
|
new AbortController().signal
|
|
)) {
|
|
void _event
|
|
}
|
|
|
|
expect(mocks.runHost).toHaveBeenCalledWith(
|
|
'search',
|
|
expect.any(AbortSignal),
|
|
expect.any(Function),
|
|
{
|
|
workMode: 'ask',
|
|
knowledgeCapability: {
|
|
endpoint: 'http://127.0.0.1:4567/mcp',
|
|
token: 'main-only-token'
|
|
},
|
|
onEvent: expect.any(Function)
|
|
}
|
|
)
|
|
const authorize = mocks.runHost.mock.calls[0]?.[2]
|
|
await expect(
|
|
authorize?.({ toolName: 'knowledge_search' })
|
|
).resolves.toBe('once')
|
|
await expect(authorize?.({ toolName: 'Bash' })).resolves.toBe('deny')
|
|
})
|
|
|
|
it('adds assigned Skill instructions to the Continue prompt', async () => {
|
|
const runtime = new ContinueAgentRuntime({
|
|
binaryPath: '',
|
|
configPath: 'C:\\safe config\\continue.yaml',
|
|
defaultWorkspace: process.cwd(),
|
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
|
skillInstructions: '# 周报助手',
|
|
createHostAdapter: () => ({
|
|
getPreparedHost: mocks.prepareHost,
|
|
run: mocks.runHost,
|
|
dispose: mocks.disposeHost
|
|
})
|
|
})
|
|
|
|
await collectEvents(runtime)
|
|
|
|
const prompt = String(mocks.runHost.mock.calls[0]?.[0])
|
|
expect(prompt).toContain('SYSTEM CAPABILITY INSTRUCTIONS')
|
|
expect(prompt).toContain('# 周报助手')
|
|
expect(prompt).toContain('test')
|
|
})
|
|
|
|
it('keeps a full bundled Skill payload on every platform', async () => {
|
|
const runtime = new ContinueAgentRuntime({
|
|
binaryPath: '',
|
|
configPath: 'C:\\safe config\\continue.yaml',
|
|
defaultWorkspace: process.cwd(),
|
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
|
skillInstructions: `# Skills\n${'技'.repeat(30_000)}`.slice(0, 30_000),
|
|
createHostAdapter: () => ({
|
|
getPreparedHost: mocks.prepareHost,
|
|
run: mocks.runHost,
|
|
dispose: mocks.disposeHost
|
|
})
|
|
})
|
|
|
|
await collectEvents(runtime)
|
|
|
|
const prompt = String(mocks.runHost.mock.calls[0]?.[0])
|
|
expect(prompt).toContain('SYSTEM CAPABILITY INSTRUCTIONS')
|
|
expect(prompt.length).toBeGreaterThan(24_000)
|
|
})
|
|
|
|
it('reports oversized Skill payloads instead of dropping them silently', async () => {
|
|
const runtime = new ContinueAgentRuntime({
|
|
binaryPath: '',
|
|
configPath: 'C:\\safe config\\continue.yaml',
|
|
defaultWorkspace: process.cwd(),
|
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
|
skillInstructions: '巨'.repeat(130_000),
|
|
createHostAdapter: () => ({
|
|
getPreparedHost: mocks.prepareHost,
|
|
run: mocks.runHost,
|
|
dispose: mocks.disposeHost
|
|
})
|
|
})
|
|
|
|
await expect(collectEvents(runtime)).rejects.toThrow('超过 Continue')
|
|
expect(mocks.runHost).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('blocks anonymous platform fallback without an explicit model configuration', async () => {
|
|
const runtime = new ContinueAgentRuntime({
|
|
binaryPath: '',
|
|
configPath: '',
|
|
defaultWorkspace: process.cwd(),
|
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
|
createHostAdapter: () => ({
|
|
getPreparedHost: mocks.prepareHost,
|
|
run: mocks.runHost,
|
|
dispose: mocks.disposeHost
|
|
})
|
|
})
|
|
|
|
await expect(runtime.getStatus()).resolves.toMatchObject({
|
|
available: false,
|
|
detail: expect.stringContaining('尚未配置模型连接')
|
|
})
|
|
const stream = runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'test',
|
|
workMode: 'execute'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
await expect(stream.next()).rejects.toThrow('尚未配置模型连接')
|
|
expect(mocks.detectRuntimeBinary).not.toHaveBeenCalled()
|
|
expect(mocks.prepareHost).not.toHaveBeenCalled()
|
|
expect(mocks.runHost).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('places the current request before untrusted conversation history', async () => {
|
|
const runtime = createRuntime()
|
|
for await (const _event of runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'current request',
|
|
history: [
|
|
{ role: 'user', content: 'previous request' },
|
|
{ role: 'assistant', content: 'previous response' }
|
|
]
|
|
},
|
|
new AbortController().signal
|
|
)) {
|
|
expect(_event).toBeDefined()
|
|
}
|
|
|
|
const prompt = String(mocks.runHost.mock.calls[0]?.[0])
|
|
expect(prompt.indexOf('current request')).toBeLessThan(
|
|
prompt.indexOf('previous response')
|
|
)
|
|
expect(prompt).toContain('Answer the CURRENT USER REQUEST now.')
|
|
expect(prompt).not.toContain('\n')
|
|
})
|
|
|
|
it('ignores the synthetic greeting when there is no prior user turn', async () => {
|
|
const runtime = createRuntime()
|
|
for await (const event of runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'current request',
|
|
history: [{ role: 'assistant', content: 'synthetic greeting' }]
|
|
},
|
|
new AbortController().signal
|
|
)) {
|
|
expect(event).toBeDefined()
|
|
}
|
|
|
|
expect(mocks.runHost.mock.calls[0]?.[0]).toBe('current request')
|
|
})
|
|
|
|
it('reuses discovery for availability and reports safe diagnostics', async () => {
|
|
mocks.detectRuntimeBinary.mockResolvedValue({
|
|
available: false,
|
|
detail: '未自动检测到 Continue CLI,请配置绝对二进制路径'
|
|
})
|
|
const runtime = createRuntime()
|
|
|
|
await expect(runtime.getStatus()).resolves.toEqual({
|
|
id: 'continue',
|
|
label: 'Continue CLI',
|
|
available: false,
|
|
supportsToolExecution: true,
|
|
detail: '未自动检测到 Continue CLI,请配置绝对二进制路径'
|
|
})
|
|
const stream = runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'test'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
await expect(stream.next()).rejects.toThrow(
|
|
'未自动检测到 Continue CLI'
|
|
)
|
|
expect(mocks.detectRuntimeBinary).toHaveBeenCalledOnce()
|
|
expect(mocks.runHost).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('auto-allows host tool requests without using GoodBuddy approval', async () => {
|
|
const runtime = createRuntime()
|
|
const stream = runtime.run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'test',
|
|
workMode: 'execute'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
for await (const event of stream) {
|
|
expect(event).toBeDefined()
|
|
}
|
|
|
|
const hostAuthorize = mocks.runHost.mock.calls[0]?.[2] as
|
|
| (() => Promise<string>)
|
|
| undefined
|
|
await expect(hostAuthorize?.()).resolves.toBe('once')
|
|
})
|
|
|
|
it('keeps non-interactive Ask runs read-only', async () => {
|
|
const modes: Array<'chat' | 'agent' | undefined> = []
|
|
const runtime = new ContinueAgentRuntime({
|
|
binaryPath: '',
|
|
configPath: 'C:\\safe config\\continue.yaml',
|
|
defaultWorkspace: process.cwd(),
|
|
hostCacheRoot: 'C:\\safe\\continue-host',
|
|
createHostAdapter: (options) => {
|
|
modes.push(options.mode)
|
|
return {
|
|
getPreparedHost: mocks.prepareHost,
|
|
run: mocks.runHost,
|
|
dispose: mocks.disposeHost
|
|
}
|
|
}
|
|
})
|
|
|
|
await collectEvents(runtime, 'ask')
|
|
await collectEvents(runtime, 'execute')
|
|
|
|
expect(modes).toEqual(['chat', 'agent'])
|
|
const askAuthorize = mocks.runHost.mock.calls[0]?.[2] as
|
|
| (() => Promise<string>)
|
|
| undefined
|
|
const executeAuthorize = mocks.runHost.mock.calls[1]?.[2] as
|
|
| (() => Promise<string>)
|
|
| undefined
|
|
await expect(askAuthorize?.()).resolves.toBe('deny')
|
|
await expect(executeAuthorize?.()).resolves.toBe('once')
|
|
})
|
|
|
|
it('emits completed audit events for Continue tools', async () => {
|
|
mocks.runHost.mockResolvedValue({
|
|
text: 'Continue response',
|
|
tools: [
|
|
{
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'completed',
|
|
input: '{"command":"npm test"}',
|
|
output: 'Tests passed'
|
|
},
|
|
{ callId: 'call-2', name: 'Write', state: 'completed' }
|
|
]
|
|
})
|
|
|
|
const events = await collectEvents(createRuntime(), 'execute')
|
|
|
|
expect(events.filter((event) => event.type === 'tool')).toEqual([
|
|
expect.objectContaining({
|
|
type: 'tool',
|
|
name: 'Bash',
|
|
state: 'completed',
|
|
summary: 'Continue 工具:Bash',
|
|
input: '{"command":"npm test"}',
|
|
output: 'Tests passed'
|
|
}),
|
|
expect.objectContaining({
|
|
type: 'tool',
|
|
name: 'Write',
|
|
state: 'completed',
|
|
summary: 'Continue 工具:Write'
|
|
})
|
|
])
|
|
})
|
|
|
|
it('forwards streamed text and tool events in host order', async () => {
|
|
mocks.runHost.mockImplementation(
|
|
async (
|
|
_prompt,
|
|
_signal,
|
|
_authorize,
|
|
options
|
|
) => {
|
|
await options?.onEvent?.({
|
|
type: 'text',
|
|
delta: '先分析'
|
|
})
|
|
await options?.onEvent?.({
|
|
type: 'tool',
|
|
tool: {
|
|
callId: 'call-1',
|
|
name: 'Read',
|
|
state: 'running'
|
|
}
|
|
})
|
|
await options?.onEvent?.({
|
|
type: 'tool',
|
|
tool: {
|
|
callId: 'call-1',
|
|
name: 'Read',
|
|
state: 'completed'
|
|
}
|
|
})
|
|
await options?.onEvent?.({
|
|
type: 'text',
|
|
delta: '再回答'
|
|
})
|
|
return {
|
|
text: '再回答',
|
|
streamedText: true,
|
|
tools: [
|
|
{
|
|
callId: 'call-1',
|
|
name: 'Read',
|
|
state: 'completed'
|
|
}
|
|
]
|
|
}
|
|
}
|
|
)
|
|
|
|
const events = await collectEvents(createRuntime(), 'execute')
|
|
|
|
expect(
|
|
events.filter(
|
|
(event) => event.type === 'text' || event.type === 'tool'
|
|
)
|
|
).toEqual([
|
|
expect.objectContaining({ type: 'text', delta: '先分析' }),
|
|
expect.objectContaining({
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
state: 'running'
|
|
}),
|
|
expect.objectContaining({
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
state: 'completed'
|
|
}),
|
|
expect.objectContaining({ type: 'text', delta: '再回答' })
|
|
])
|
|
})
|
|
|
|
it('emits terminal tool audits before a failed Continue run', async () => {
|
|
mocks.runHost.mockRejectedValue(
|
|
new ContinueHostRunError('Continue failed', {
|
|
cause: new Error('failed'),
|
|
tools: [
|
|
{
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'failed',
|
|
error: 'PowerShell parser failed'
|
|
}
|
|
]
|
|
})
|
|
)
|
|
const stream = createRuntime().run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'test',
|
|
workMode: 'execute'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
|
|
await expect(stream.next()).resolves.toMatchObject({
|
|
value: { type: 'status' }
|
|
})
|
|
await expect(stream.next()).resolves.toMatchObject({
|
|
value: {
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
state: 'failed',
|
|
error: 'PowerShell parser failed'
|
|
}
|
|
})
|
|
await expect(stream.next()).rejects.toThrow('Continue failed')
|
|
})
|
|
|
|
it('keeps a completed Continue response when an earlier tool attempt failed', async () => {
|
|
mocks.runHost.mockResolvedValue({
|
|
text: 'Continue response',
|
|
tools: [
|
|
{
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'failed',
|
|
error: 'PowerShell EmptyPipeElement'
|
|
}
|
|
]
|
|
})
|
|
const stream = createRuntime().run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'test',
|
|
workMode: 'execute'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
|
|
await expect(stream.next()).resolves.toMatchObject({
|
|
value: { type: 'status' }
|
|
})
|
|
const events: RuntimeEvent[] = []
|
|
for await (const event of stream) {
|
|
events.push(event)
|
|
}
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
state: 'recoverable',
|
|
error: 'PowerShell EmptyPipeElement'
|
|
})
|
|
)
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'text',
|
|
delta: 'Continue response'
|
|
})
|
|
)
|
|
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
|
})
|
|
|
|
it('fails a run that returns a nonterminal tool state', async () => {
|
|
mocks.runHost.mockResolvedValue({
|
|
text: 'Continue response',
|
|
tools: [
|
|
{
|
|
callId: 'call-1',
|
|
name: 'Bash',
|
|
state: 'running'
|
|
}
|
|
]
|
|
})
|
|
const stream = createRuntime().run(
|
|
{
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'test',
|
|
workMode: 'execute'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
|
|
await expect(stream.next()).resolves.toMatchObject({
|
|
value: { type: 'status' }
|
|
})
|
|
await expect(stream.next()).resolves.toMatchObject({
|
|
value: { type: 'tool', state: 'failed' }
|
|
})
|
|
await expect(stream.next()).rejects.toThrow('工具未完成')
|
|
})
|
|
})
|