feat: add persistent desktop assistant workspace

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-31 22:33:03 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 698a15ad14
commit 6ef1795b81
101 changed files with 31866 additions and 1176 deletions
+205 -6
View File
@@ -1,12 +1,70 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent } from '../../shared/contracts'
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'
describe('ContinueAgentRuntime', () => {
it('does not launch the CLI for an already-cancelled request', async () => {
const runtime = new ContinueAgentRuntime({
command: 'command-that-must-not-run',
defaultWorkspace: process.cwd()
function createRuntime(): ContinueAgentRuntime {
return new ContinueAgentRuntime({
binaryPath: '',
configPath: 'C:\\safe config\\continue.yaml',
mode: 'chat',
defaultWorkspace: process.cwd(),
hostCacheRoot: 'C:\\safe\\continue-host',
createHostAdapter: () => ({
getPreparedHost: mocks.prepareHost,
run: mocks.runHost,
dispose: mocks.disposeHost
})
})
}
async function collectEvents(
runtime: ContinueAgentRuntime
): Promise<AgentEvent[]> {
const events: AgentEvent[] = []
for await (const event of runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test'
},
new AbortController().signal,
vi.fn(async () => 'once' as const)
)) {
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('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(
@@ -19,5 +77,146 @@ describe('ContinueAgentRuntime', () => {
)
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: '',
binaryNames: ['cn'],
label: 'Continue CLI'
})
expect(mocks.runHost).toHaveBeenCalledWith(
'test',
expect.any(AbortSignal),
expect.any(Function)
)
expect(events).toContainEqual({
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
type: 'text',
delta: 'Continue response'
})
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('does not require whole-run approval', () => {
const runtime = createRuntime()
expect(runtime.requiresToolApproval).toBe(false)
})
it('adds assigned Skill instructions to the Continue prompt', async () => {
const runtime = new ContinueAgentRuntime({
binaryPath: '',
configPath: '',
mode: 'chat',
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('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,
vi.fn(async () => 'once' as const)
)) {
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,
vi.fn(async () => 'once' as const)
)) {
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,
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('requires the host approval callback', async () => {
const runtime = createRuntime()
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).rejects.toThrow('审批服务不可用')
})
})