fix: preserve drafts, canvas edits, and remote Continue tools
Keep paragraph formatting and checklist todos when removing canvas pages, and preserve accepted undo/redo history after oversized input is rejected. Merge restored queued text into the latest conversation draft and retain image delivery when OCR returns image references and generated warnings only. Deliver session MCP servers to bridged remote Continue Execute while keeping Ask read-only and preserving local model-profile isolation. Add regression coverage and update feature documentation. Correct stale conversation-limit and forced-color assertions and the measured Electron layout test deadline. Validation: 4,826 tests passed, 67 skipped; full typecheck and lint passed. Current-source Linux x64 Execute/Ask/Execute MCP delivery passed, using 10 real text-provider requests and no image-provider requests. Image generation itself was not exercised. Staged whitespace checks passed before commit. Release note: 修复恢复队列时覆盖新输入、画布删页丢失清单格式及超限输入清空撤销历史的问题;图片 OCR 无正文时保留原图发送方式,远程 Continue Execute 正确保留会话 MCP 工具。
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { readFile, rm, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { Agent } from '@agentclientprotocol/sdk'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { ContinueHostAdapter, type ContinueHostRunOptions } from '../main/agent/continue-host-adapter'
|
||||
import { runContinueAcpHelper } from './continue-acp-helper'
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
agent: undefined as Agent | undefined,
|
||||
close: () => {},
|
||||
mode: 'execute' as 'ask' | 'execute'
|
||||
}))
|
||||
vi.mock('@agentclientprotocol/sdk', () => ({
|
||||
PROTOCOL_VERSION: 1,
|
||||
ndJsonStream: vi.fn(),
|
||||
AgentSideConnection: class {
|
||||
closed = new Promise<void>(resolve => { transport.close = resolve })
|
||||
constructor(factory: () => Agent) { transport.agent = factory() }
|
||||
sessionUpdate = vi.fn()
|
||||
extNotification = vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('./model-bridge-helper', () => ({
|
||||
MODEL_BRIDGE_SDK_AUTH_SENTINEL: 'bridge-test-sentinel',
|
||||
openCodeModelBridgeModelId: () => 'bridge-model',
|
||||
ModelBridgeLoopbackProxy: class {
|
||||
listen = async () => 'http://127.0.0.1:12345'
|
||||
close = async () => {}
|
||||
}
|
||||
}))
|
||||
vi.mock('./model-bridge-broker', () => ({
|
||||
createUnixModelBridgeExchange: vi.fn()
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
transport.agent = undefined
|
||||
})
|
||||
|
||||
const server = (name: string) => ({
|
||||
name, type: 'http' as const, url: `http://127.0.0.1:12346/${name}`,
|
||||
headers: [{ name: 'Authorization', value: 'Bearer session-test-token' }]
|
||||
})
|
||||
|
||||
// Keep the actual helper and adapter config builder together: mocking the
|
||||
// adapter constructor would miss the model-profile configuration-loss bug.
|
||||
it('delivers current ACP session MCP capabilities through the bridged model config, but not Ask', async () => {
|
||||
const configs: Array<Record<string, unknown>> = []
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
|
||||
operationId: 'operation', workMode: transport.mode
|
||||
})))
|
||||
vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async function (
|
||||
this: ContinueHostAdapter, _prompt, _signal, _authorize, options
|
||||
) {
|
||||
const path = await (this as unknown as {
|
||||
createRunConfig(options: ContinueHostRunOptions): Promise<string>
|
||||
}).createRunConfig(options!)
|
||||
configs.push(JSON.parse(await readFile(path, 'utf8')))
|
||||
await rm(path)
|
||||
return { text: 'OK' }
|
||||
})
|
||||
const running = runContinueAcpHelper({
|
||||
socketPath: 'unused', protocol: 'openai-chat-completions', model: 'test-model',
|
||||
supportsImageInput: false, workMode: 'execute', sharedSessions: true, entrypoint: 'unused'
|
||||
})
|
||||
try {
|
||||
await vi.waitFor(() => expect(transport.agent).toBeDefined())
|
||||
const agent = transport.agent!
|
||||
const { sessionId } = await agent.newSession({ cwd: tmpdir(), mcpServers: [server('first')] })
|
||||
const prompt = () => agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'OK' }] })
|
||||
await prompt()
|
||||
await agent.resumeSession!({ sessionId, cwd: tmpdir(), mcpServers: [server('replacement')] })
|
||||
await prompt()
|
||||
transport.mode = 'ask'
|
||||
await prompt()
|
||||
transport.mode = 'execute'
|
||||
await agent.loadSession!({ sessionId, cwd: tmpdir(), mcpServers: [] })
|
||||
await prompt()
|
||||
expect(configs.map(config => config.mcpServers ?? [])).toEqual([
|
||||
[{ name: 'first', type: 'streamable-http', url: server('first').url,
|
||||
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }],
|
||||
[{ name: 'replacement', type: 'streamable-http', url: server('replacement').url,
|
||||
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }],
|
||||
[], []
|
||||
])
|
||||
expect(configs[0].models).toEqual([expect.objectContaining({ model: 'test-model' })])
|
||||
} finally {
|
||||
transport.close()
|
||||
await running
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps local profile MCP scoping and filters explicit session servers in Ask at the adapter boundary', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cn-scope-'))
|
||||
try {
|
||||
const configPath = join(root, 'native.json')
|
||||
await writeFile(configPath, JSON.stringify({ mcpServers: [{ name: 'local-native', command: 'unused' }] }))
|
||||
const adapter = new ContinueHostAdapter({
|
||||
binaryPath: 'unused', cacheRoot: root, workspace: root, configPath,
|
||||
modelProfile: { id: 'test', name: 'test', modelName: 'test',
|
||||
protocol: 'openai-chat-completions', authentication: 'none', baseUrl: 'http://127.0.0.1:12345' }
|
||||
})
|
||||
const create = (options: ContinueHostRunOptions) => (adapter as unknown as {
|
||||
createRunConfig(options: ContinueHostRunOptions): Promise<string>
|
||||
}).createRunConfig(options)
|
||||
for (const workMode of ['ask', 'execute'] as const) {
|
||||
const path = await create({ workMode })
|
||||
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([])
|
||||
}
|
||||
const path = await create({ workMode: 'ask', sessionMcpServers: [{
|
||||
name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} }
|
||||
}] })
|
||||
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
@@ -88,15 +88,13 @@ export async function runContinueAcpHelper(options: {
|
||||
return await response.json() as { operationId: string; workMode: 'ask' | 'execute' }
|
||||
}) : undefined
|
||||
const workMode = route?.workMode ?? options.workMode
|
||||
const configPath = join(root, `${sessionId}.json`)
|
||||
await writeFile(configPath, JSON.stringify({ name: 'GoodBuddy', version: '1.0.0', schema: 'v1',
|
||||
mcpServers: workMode === 'execute' ? session.mcpServers.map(server => {
|
||||
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
|
||||
return { name: server.name, type: 'streamable-http', url: server.url,
|
||||
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
|
||||
}) : [] }), { mode: 0o600 })
|
||||
const sessionMcpServers = workMode === 'execute' ? session.mcpServers.map(server => {
|
||||
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
|
||||
return { name: server.name, type: 'streamable-http' as const, url: server.url,
|
||||
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
|
||||
}) : []
|
||||
const adapter = new ContinueHostAdapter({
|
||||
binaryPath: options.entrypoint, configPath, workspace: session.cwd, cacheRoot: root, mode: 'chat',
|
||||
binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'chat',
|
||||
modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model,
|
||||
protocol: options.protocol, authentication: 'api-key', apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||
baseUrl: `${origin}/v1`, supportsImageInput: options.supportsImageInput,
|
||||
@@ -114,7 +112,7 @@ export async function runContinueAcpHelper(options: {
|
||||
try {
|
||||
const result = await adapter.run(promptWithUntrustedConversationHistory({ prompt: text, history: session.history }, true), abort.signal,
|
||||
async () => workMode === 'execute' ? 'once' : 'deny', {
|
||||
workMode, images,
|
||||
workMode, images, sessionMcpServers,
|
||||
onEvent: async event => {
|
||||
if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: {
|
||||
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: event.delta }
|
||||
@@ -156,7 +154,6 @@ export async function runContinueAcpHelper(options: {
|
||||
} finally {
|
||||
await adapter.dispose()
|
||||
for (const [id, pending] of questions) if (pending.adapter === adapter) questions.delete(id)
|
||||
await rm(configPath, { force: true })
|
||||
session.active = undefined
|
||||
finish()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user