chore: prepare GoodBuddy 0.8.6
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
This commit is contained in:
@@ -77,7 +77,18 @@ async function createDistribution(version = '1.5.47'): Promise<{
|
||||
'listen(i,async()=>{console.log(Ht.green(`Server started on http://localhost:${i}`))',
|
||||
'async function SCt(e){return n5e||',
|
||||
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}',
|
||||
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}'
|
||||
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}',
|
||||
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-ExecutionPolicy","Bypass","-Command",e]}',
|
||||
'a={onContent:u=>{},onContentComplete:u=>{},onToolStart:(u,l)=>{},onToolResult:(u,l,c)=>{},onToolError:(u,l)=>{},onToolPermissionRequest:',
|
||||
'pendingPermission:null},B=',
|
||||
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission);Te.json(ue)})',
|
||||
'n?.onToolStart?.(i.name,i.arguments);',
|
||||
'n?.onToolError?.(l,i.name)',
|
||||
't?.onToolStart?.(c.name,c.arguments);',
|
||||
't?.onToolResult?.(String(y.content),c.name,"canceled")',
|
||||
't?.onToolResult?.(f,c.name,"done")',
|
||||
't?.onToolError?.(g,c.name)',
|
||||
't?.onToolError?.(p,c.name)'
|
||||
].join(';')
|
||||
await writeFile(join(distribution, 'index.js'), sourceBundle, 'utf8')
|
||||
return {
|
||||
@@ -138,6 +149,12 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(bundle).toContain(
|
||||
'useResponsesApi:e.useResponsesApi'
|
||||
)
|
||||
expect(bundle).toContain('"-NoProfile"')
|
||||
expect(bundle).toContain('[Console]::OutputEncoding')
|
||||
expect(bundle).toContain('goodbuddyEvents:[]')
|
||||
expect(bundle).toContain('goodbuddyEvents:ce')
|
||||
expect(bundle).toContain('type:"text",delta:u')
|
||||
expect(bundle).toContain('onToolStart?.(c.name,c.arguments,c.id)')
|
||||
expect(bundle).toContain(
|
||||
'function ZZo(e){let t=[];if(e.allow)'
|
||||
)
|
||||
@@ -394,7 +411,7 @@ describe('ContinueHostAdapter', () => {
|
||||
cacheWriteTokens: 0
|
||||
}
|
||||
})
|
||||
expect(launch?.entryPath).toContain('host-v4')
|
||||
expect(launch?.entryPath).toContain('host-v6')
|
||||
expect(launch?.args).toEqual([
|
||||
'--config',
|
||||
expect.stringContaining('model-config-'),
|
||||
@@ -870,6 +887,7 @@ describe('ContinueHostAdapter', () => {
|
||||
const distribution = await createDistribution()
|
||||
let launchArgs: string[] = []
|
||||
const permissionBodies: unknown[] = []
|
||||
const streamEvents: unknown[] = []
|
||||
const launchHost: ContinueHostLauncher = (
|
||||
_entryPath,
|
||||
args
|
||||
@@ -915,7 +933,16 @@ describe('ContinueHostAdapter', () => {
|
||||
toolName: 'Bash',
|
||||
toolArgs: { command: 'npm test' },
|
||||
requestId: 'permission-1'
|
||||
}
|
||||
},
|
||||
goodbuddyEvents: [
|
||||
{ type: 'text', delta: '先检查命令。' },
|
||||
{
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'running'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
return Response.json({
|
||||
@@ -940,7 +967,16 @@ describe('ContinueHostAdapter', () => {
|
||||
},
|
||||
isProcessing: false,
|
||||
messageQueueLength: 0,
|
||||
pendingPermission: null
|
||||
pendingPermission: null,
|
||||
goodbuddyEvents: [
|
||||
{
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed'
|
||||
},
|
||||
{ type: 'text', delta: 'TOOLS_OK' }
|
||||
]
|
||||
})
|
||||
}
|
||||
return Response.json({})
|
||||
@@ -959,9 +995,19 @@ describe('ContinueHostAdapter', () => {
|
||||
const authorize = vi.fn(async () => 'once' as const)
|
||||
|
||||
await expect(
|
||||
adapter.run('hello', new AbortController().signal, authorize)
|
||||
adapter.run(
|
||||
'hello',
|
||||
new AbortController().signal,
|
||||
authorize,
|
||||
{
|
||||
onEvent: (event) => {
|
||||
streamEvents.push(event)
|
||||
}
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({
|
||||
text: 'TOOLS_OK',
|
||||
streamedText: true,
|
||||
tools: [
|
||||
{
|
||||
callId: 'call-1',
|
||||
@@ -970,6 +1016,26 @@ describe('ContinueHostAdapter', () => {
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(streamEvents).toEqual([
|
||||
{ type: 'text', delta: '先检查命令。' },
|
||||
{
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'running'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed'
|
||||
}
|
||||
},
|
||||
{ type: 'text', delta: 'TOOLS_OK' }
|
||||
])
|
||||
expect(launchArgs).not.toContain('--readonly')
|
||||
expect(authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolName: 'Bash' })
|
||||
|
||||
@@ -45,6 +45,7 @@ const maximumBundleBytes = 32 * 1024 * 1024
|
||||
const maximumStateBytes = 8 * 1024 * 1024
|
||||
const maximumConfigBytes = 1024 * 1024
|
||||
const maximumConfiguredMcpServers = 100
|
||||
const maximumStreamEvents = 5_000
|
||||
const knowledgeMcpName = 'goodbuddy-knowledge'
|
||||
export const continueConfigurationRequiredMessage =
|
||||
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
|
||||
@@ -74,6 +75,24 @@ const sessionUsageSchema = z.object({
|
||||
.optional()
|
||||
})
|
||||
|
||||
const continueHostStreamEventSchema = z.discriminatedUnion('type', [
|
||||
z
|
||||
.object({
|
||||
type: z.literal('text'),
|
||||
delta: z.string().min(1).max(100_000)
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('tool'),
|
||||
callId: z.string().min(1).max(256),
|
||||
name: z.string().min(1).max(200),
|
||||
state: z.enum(['running', 'completed', 'failed']),
|
||||
error: z.string().max(1_000).optional()
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
const stateSchema = z.object({
|
||||
session: z.object({
|
||||
history: z.array(z.unknown()).max(5_000),
|
||||
@@ -88,7 +107,11 @@ const stateSchema = z.object({
|
||||
requestId: z.string().min(1).max(256),
|
||||
toolCallPreview: z.array(z.unknown()).max(100).optional()
|
||||
})
|
||||
.nullable()
|
||||
.nullable(),
|
||||
goodbuddyEvents: z
|
||||
.array(continueHostStreamEventSchema)
|
||||
.max(maximumStreamEvents)
|
||||
.optional()
|
||||
})
|
||||
|
||||
type ContinueHostState = z.infer<typeof stateSchema>
|
||||
@@ -124,10 +147,15 @@ export type ContinueHostTool = {
|
||||
|
||||
export type ContinueHostRunResult = {
|
||||
text: string
|
||||
streamedText?: true
|
||||
usage?: ContinueHostUsage
|
||||
tools?: ContinueHostTool[]
|
||||
}
|
||||
|
||||
export type ContinueHostStreamEvent =
|
||||
| { type: 'text'; delta: string }
|
||||
| { type: 'tool'; tool: ContinueHostTool }
|
||||
|
||||
export class ContinueHostRunError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -158,6 +186,7 @@ export type ContinueHostRunOptions = {
|
||||
endpoint: string
|
||||
token: string
|
||||
}
|
||||
onEvent?: (event: ContinueHostStreamEvent) => void | Promise<void>
|
||||
}
|
||||
|
||||
type KnowledgeCapability = NonNullable<
|
||||
@@ -434,7 +463,7 @@ function extractContinueTools(
|
||||
: 'failed'
|
||||
const error =
|
||||
normalizedState === 'failed'
|
||||
? safeToolErrorDetail(state.output)
|
||||
? normalizeContinueToolError(state.output)
|
||||
: undefined
|
||||
tools.set(callId, {
|
||||
callId,
|
||||
@@ -447,6 +476,28 @@ function extractContinueTools(
|
||||
return [...tools.values()]
|
||||
}
|
||||
|
||||
function mergeContinueTools(
|
||||
current: ContinueHostTool[],
|
||||
updates: ContinueHostTool[]
|
||||
): ContinueHostTool[] {
|
||||
const tools = new Map(current.map((tool) => [tool.callId, tool]))
|
||||
for (const tool of updates) {
|
||||
tools.set(tool.callId, tool)
|
||||
}
|
||||
return [...tools.values()]
|
||||
}
|
||||
|
||||
function normalizeContinueToolError(value: unknown): string | undefined {
|
||||
const detail = safeToolErrorDetail(value)
|
||||
if (!detail) {
|
||||
return undefined
|
||||
}
|
||||
const replacementCharacters = detail.match(/\uFFFD/gu)?.length ?? 0
|
||||
return replacementCharacters >= 3
|
||||
? 'PowerShell 输出编码异常,原始错误无法安全显示;请重试该命令'
|
||||
: detail
|
||||
}
|
||||
|
||||
function subtractTokenCount(completed: number, initial: number): number {
|
||||
return Math.max(0, completed - initial)
|
||||
}
|
||||
@@ -538,6 +589,25 @@ export class ContinueHostAdapter {
|
||||
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}'
|
||||
const modelConfigurationMarker =
|
||||
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}'
|
||||
const windowsShellMarker =
|
||||
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-ExecutionPolicy","Bypass","-Command",e]}'
|
||||
const streamCallbacksMarker =
|
||||
'a={onContent:u=>{},onContentComplete:u=>{},onToolStart:(u,l)=>{},onToolResult:(u,l,c)=>{},onToolError:(u,l)=>{},onToolPermissionRequest:'
|
||||
const serverStateMarker = 'pendingPermission:null},B='
|
||||
const serverStateEndpointMarker =
|
||||
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission);Te.json(ue)})'
|
||||
const preprocessToolStartMarker =
|
||||
'n?.onToolStart?.(i.name,i.arguments);'
|
||||
const preprocessToolErrorMarker =
|
||||
'n?.onToolError?.(l,i.name)'
|
||||
const executeToolStartMarker =
|
||||
't?.onToolStart?.(c.name,c.arguments);'
|
||||
const cancelledToolResultMarker =
|
||||
't?.onToolResult?.(String(y.content),c.name,"canceled")'
|
||||
const completedToolResultMarker =
|
||||
't?.onToolResult?.(f,c.name,"done")'
|
||||
const failedToolResultMarker = 't?.onToolError?.(g,c.name)'
|
||||
const permissionToolErrorMarker = 't?.onToolError?.(p,c.name)'
|
||||
let patched = replaceExactly(
|
||||
sourceBundle,
|
||||
serveInitializationMarker,
|
||||
@@ -583,11 +653,66 @@ export class ContinueHostAdapter {
|
||||
modelConfigurationMarker,
|
||||
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env,useResponsesApi:e.useResponsesApi};return CGn(n)??null}'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
windowsShellMarker,
|
||||
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-NoProfile","-ExecutionPolicy","Bypass","-Command",\'[Console]::InputEncoding=[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);$OutputEncoding=[Console]::OutputEncoding;\'+e]}'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
streamCallbacksMarker,
|
||||
'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running"})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed"})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
serverStateMarker,
|
||||
'pendingPermission:null,goodbuddyEvents:[]},B='
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
serverStateEndpointMarker,
|
||||
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission),ce=M.goodbuddyEvents.splice(0);Te.json({...ue,goodbuddyEvents:ce})})'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
preprocessToolStartMarker,
|
||||
'n?.onToolStart?.(i.name,i.arguments,i.id);'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
preprocessToolErrorMarker,
|
||||
'n?.onToolError?.(l,i.name,i.id)'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
executeToolStartMarker,
|
||||
't?.onToolStart?.(c.name,c.arguments,c.id);'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
cancelledToolResultMarker,
|
||||
't?.onToolResult?.(String(y.content),c.name,"canceled",c.id)'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
completedToolResultMarker,
|
||||
't?.onToolResult?.(f,c.name,"done",c.id)'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
failedToolResultMarker,
|
||||
't?.onToolError?.(g,c.name,c.id)'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
permissionToolErrorMarker,
|
||||
't?.onToolError?.(p,c.name,c.id)'
|
||||
)
|
||||
const patchedHash = hashContents(patched)
|
||||
const digest = sourceHash.slice(0, 16)
|
||||
const targetRoot = join(
|
||||
this.options.cacheRoot,
|
||||
`host-v4-${supportedVersion}-${digest}`
|
||||
`host-v6-${supportedVersion}-${digest}`
|
||||
)
|
||||
const targetDist = join(targetRoot, 'dist')
|
||||
const targetBundle = join(targetDist, 'index.js')
|
||||
@@ -942,6 +1067,7 @@ export class ContinueHostAdapter {
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
|
||||
let observedTools: ContinueHostTool[] = []
|
||||
let streamedText = false
|
||||
try {
|
||||
const initialState = await this.waitForStartup(
|
||||
child,
|
||||
@@ -972,10 +1098,27 @@ export class ContinueHostAdapter {
|
||||
const state = stateSchema.parse(
|
||||
await this.request(origin, token, '/state', { signal })
|
||||
)
|
||||
observedTools = extractContinueTools(
|
||||
state.session.history,
|
||||
startIndex
|
||||
observedTools = mergeContinueTools(
|
||||
observedTools,
|
||||
extractContinueTools(state.session.history, startIndex)
|
||||
)
|
||||
for (const event of state.goodbuddyEvents ?? []) {
|
||||
if (event.type === 'text') {
|
||||
streamedText = true
|
||||
await runOptions.onEvent?.(event)
|
||||
continue
|
||||
}
|
||||
const tool: ContinueHostTool = {
|
||||
callId: event.callId,
|
||||
name: event.name,
|
||||
state: event.state,
|
||||
...(event.error
|
||||
? { error: normalizeContinueToolError(event.error) }
|
||||
: {})
|
||||
}
|
||||
observedTools = mergeContinueTools(observedTools, [tool])
|
||||
await runOptions.onEvent?.({ type: 'tool', tool })
|
||||
}
|
||||
const pending = state.pendingPermission
|
||||
if (pending && !handledPermissionIds.has(pending.requestId)) {
|
||||
if (handledPermissionIds.size >= 100) {
|
||||
@@ -1053,6 +1196,7 @@ export class ContinueHostAdapter {
|
||||
)
|
||||
return {
|
||||
text,
|
||||
...(streamedText ? { streamedText: true as const } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(observedTools.length > 0
|
||||
? { tools: observedTools }
|
||||
|
||||
@@ -101,7 +101,10 @@ describe('ContinueAgentRuntime', () => {
|
||||
expect(mocks.runHost).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
onEvent: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(events).toContainEqual({
|
||||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||||
@@ -189,7 +192,8 @@ describe('ContinueAgentRuntime', () => {
|
||||
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]
|
||||
@@ -403,6 +407,74 @@ describe('ContinueAgentRuntime', () => {
|
||||
])
|
||||
})
|
||||
|
||||
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', {
|
||||
@@ -441,7 +513,7 @@ describe('ContinueAgentRuntime', () => {
|
||||
await expect(stream.next()).rejects.toThrow('Continue failed')
|
||||
})
|
||||
|
||||
it('returns a failed Continue tool detail through AgentRuntime', async () => {
|
||||
it('keeps a completed Continue response when an earlier tool attempt failed', async () => {
|
||||
mocks.runHost.mockResolvedValue({
|
||||
text: 'Continue response',
|
||||
tools: [
|
||||
@@ -466,17 +538,25 @@ describe('ContinueAgentRuntime', () => {
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: { type: 'status' }
|
||||
})
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of stream) {
|
||||
events.push(event)
|
||||
}
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'failed',
|
||||
state: 'recoverable',
|
||||
error: 'PowerShell EmptyPipeElement'
|
||||
}
|
||||
})
|
||||
await expect(stream.next()).rejects.toThrow(
|
||||
'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 () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ContinueHostAdapterOptions,
|
||||
type ContinueHostLauncher,
|
||||
type ContinueHostRunResult,
|
||||
type ContinueHostStreamEvent,
|
||||
type ContinueHostTool
|
||||
} from './continue-host-adapter'
|
||||
|
||||
@@ -56,7 +57,8 @@ function continueToolFailureMessage(tool: ContinueHostTool): string {
|
||||
function toContinueToolEvent(
|
||||
requestId: string,
|
||||
tool: ContinueHostTool,
|
||||
terminalize: boolean
|
||||
terminalize: boolean,
|
||||
recoverFailure = false
|
||||
): Extract<AgentEvent, { type: 'tool' }> {
|
||||
return {
|
||||
requestId,
|
||||
@@ -64,7 +66,9 @@ function toContinueToolEvent(
|
||||
callId: tool.callId,
|
||||
name: tool.name,
|
||||
state:
|
||||
terminalize && tool.state !== 'completed'
|
||||
recoverFailure && tool.state === 'failed'
|
||||
? 'recoverable'
|
||||
: terminalize && tool.state !== 'completed'
|
||||
? 'failed'
|
||||
: tool.state,
|
||||
summary: `Continue 工具:${tool.name}`,
|
||||
@@ -283,6 +287,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
: undefined
|
||||
let result: ContinueHostRunResult
|
||||
const emittedTools = new Map<string, ContinueHostTool>()
|
||||
try {
|
||||
const host = this.getHostAdapter(
|
||||
binaryPath,
|
||||
@@ -299,17 +304,72 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
approval.toolName === 'knowledge_search')
|
||||
? 'once' as const
|
||||
: 'deny' as const
|
||||
result = knowledgeCapability
|
||||
? await host.run(
|
||||
conversationContext,
|
||||
signal,
|
||||
authorize,
|
||||
{
|
||||
workMode: request.workMode,
|
||||
knowledgeCapability
|
||||
const queuedEvents: ContinueHostStreamEvent[] = []
|
||||
let wakeStream: (() => void) | undefined
|
||||
let streamFinished = false
|
||||
let streamResult: ContinueHostRunResult | undefined
|
||||
let streamError: unknown
|
||||
const onEvent = (event: ContinueHostStreamEvent): void => {
|
||||
queuedEvents.push(event)
|
||||
wakeStream?.()
|
||||
wakeStream = undefined
|
||||
}
|
||||
const hostRun = host
|
||||
.run(
|
||||
conversationContext,
|
||||
signal,
|
||||
authorize,
|
||||
{
|
||||
workMode: request.workMode,
|
||||
...(knowledgeCapability ? { knowledgeCapability } : {}),
|
||||
onEvent
|
||||
}
|
||||
)
|
||||
.then(
|
||||
(value) => {
|
||||
streamResult = value
|
||||
},
|
||||
(error: unknown) => {
|
||||
streamError = error
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
streamFinished = true
|
||||
wakeStream?.()
|
||||
wakeStream = undefined
|
||||
})
|
||||
|
||||
while (!streamFinished || queuedEvents.length > 0) {
|
||||
if (queuedEvents.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wakeStream = resolve
|
||||
})
|
||||
continue
|
||||
}
|
||||
const event = queuedEvents.shift()!
|
||||
if (event.type === 'tool') {
|
||||
emittedTools.set(event.tool.callId, event.tool)
|
||||
}
|
||||
yield event.type === 'text'
|
||||
? {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: event.delta
|
||||
}
|
||||
)
|
||||
: await host.run(conversationContext, signal, authorize)
|
||||
: toContinueToolEvent(
|
||||
request.requestId,
|
||||
event.tool,
|
||||
false
|
||||
)
|
||||
}
|
||||
await hostRun
|
||||
if (streamError) {
|
||||
throw streamError
|
||||
}
|
||||
if (!streamResult) {
|
||||
throw new Error('Continue 宿主未返回运行结果')
|
||||
}
|
||||
result = streamResult
|
||||
} catch (error) {
|
||||
if (error instanceof ContinueHostRunError) {
|
||||
for (const tool of error.tools) {
|
||||
@@ -323,23 +383,50 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
const tools = result.tools ?? []
|
||||
const unsuccessfulTool = tools.find(
|
||||
(tool) => tool.state !== 'completed'
|
||||
const incompleteTool = tools.find(
|
||||
(tool) => tool.state === 'pending' || tool.state === 'running'
|
||||
)
|
||||
if (unsuccessfulTool) {
|
||||
if (incompleteTool) {
|
||||
for (const tool of tools) {
|
||||
yield toContinueToolEvent(request.requestId, tool, true)
|
||||
const terminalEvent = toContinueToolEvent(
|
||||
request.requestId,
|
||||
tool,
|
||||
true
|
||||
)
|
||||
const previous = emittedTools.get(tool.callId)
|
||||
if (
|
||||
!previous ||
|
||||
previous.state !== terminalEvent.state ||
|
||||
previous.error !== terminalEvent.error
|
||||
) {
|
||||
yield terminalEvent
|
||||
}
|
||||
}
|
||||
throw new Error(continueToolFailureMessage(unsuccessfulTool))
|
||||
throw new Error(continueToolFailureMessage(incompleteTool))
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
yield toContinueToolEvent(request.requestId, tool, false)
|
||||
const finalEvent = toContinueToolEvent(
|
||||
request.requestId,
|
||||
tool,
|
||||
false,
|
||||
true
|
||||
)
|
||||
const previous = emittedTools.get(tool.callId)
|
||||
if (
|
||||
!previous ||
|
||||
previous.state !== finalEvent.state ||
|
||||
previous.error !== finalEvent.error
|
||||
) {
|
||||
yield finalEvent
|
||||
}
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: result.text
|
||||
if (!result.streamedText) {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: result.text
|
||||
}
|
||||
}
|
||||
if (result.usage) {
|
||||
const usage = result.usage
|
||||
|
||||
@@ -153,6 +153,14 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
data: true,
|
||||
error: undefined
|
||||
})
|
||||
const questionReply = vi.fn().mockResolvedValue({
|
||||
data: true,
|
||||
error: undefined
|
||||
})
|
||||
const questionReject = vi.fn().mockResolvedValue({
|
||||
data: true,
|
||||
error: undefined
|
||||
})
|
||||
const client = {
|
||||
session: {
|
||||
list: vi.fn().mockResolvedValue({ data: [], error: undefined }),
|
||||
@@ -192,6 +200,10 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
permission: {
|
||||
reply: permissionReply
|
||||
},
|
||||
question: {
|
||||
reply: questionReply,
|
||||
reject: questionReject
|
||||
},
|
||||
mcp: {
|
||||
add: vi
|
||||
.fn()
|
||||
@@ -218,6 +230,8 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
client,
|
||||
callOrder,
|
||||
permissionReply,
|
||||
questionReply,
|
||||
questionReject,
|
||||
session: client.session,
|
||||
event: client.event,
|
||||
tool: client.tool
|
||||
@@ -978,6 +992,84 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
})
|
||||
|
||||
describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
it('parses OpenCode questions and sends the selected answers back', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: 'question-event',
|
||||
type: 'question.asked',
|
||||
properties: {
|
||||
id: 'question-1',
|
||||
sessionID: 'session-1',
|
||||
questions: [
|
||||
{
|
||||
header: '实现方式',
|
||||
question: '请选择实现方式',
|
||||
options: [
|
||||
{
|
||||
label: '直接修改',
|
||||
description: '立即更新现有实现'
|
||||
},
|
||||
{
|
||||
label: '先写测试',
|
||||
description: '先增加回归测试'
|
||||
}
|
||||
],
|
||||
multiple: false,
|
||||
custom: true
|
||||
}
|
||||
],
|
||||
tool: {
|
||||
messageID: 'message-1',
|
||||
callID: 'call-question-1'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'idle',
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
])
|
||||
const runtime = embeddedRuntime(setup.client)
|
||||
const stream = runtime.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: 'question',
|
||||
questionId: 'question-1',
|
||||
questions: [
|
||||
{
|
||||
header: '实现方式',
|
||||
question: '请选择实现方式',
|
||||
multiple: false,
|
||||
custom: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
await runtime.respondToQuestion('question-1', [['先写测试']])
|
||||
expect(setup.questionReply).toHaveBeenCalledWith({
|
||||
requestID: 'question-1',
|
||||
directory: process.cwd(),
|
||||
answers: [['先写测试']]
|
||||
})
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
value: { type: 'done' }
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('adds only the request-scoped knowledge MCP tool for Ask and disconnects it', async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
@@ -1420,6 +1512,33 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
delta: 'approved output'
|
||||
})
|
||||
)
|
||||
expect(
|
||||
events.filter(
|
||||
(event) =>
|
||||
event.type === 'reasoning' ||
|
||||
event.type === 'text' ||
|
||||
event.type === 'tool'
|
||||
)
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'pending'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'completed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
delta: 'reasoning output'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
delta: 'approved output'
|
||||
})
|
||||
])
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -3,12 +3,16 @@ import {
|
||||
type AssistantMessage,
|
||||
type OpencodeClient,
|
||||
type PermissionRequest,
|
||||
type PermissionRuleset
|
||||
type PermissionRuleset,
|
||||
type QuestionRequest
|
||||
} from '@opencode-ai/sdk/v2'
|
||||
import spawn from 'cross-spawn'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import type { AgentRuntimeStatus } from '../../shared/contracts'
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
|
||||
import { createOpenAIApiBaseUrl } from './openai-endpoint'
|
||||
import type {
|
||||
@@ -42,6 +46,9 @@ const MAX_PERMISSION_PATTERN_LENGTH = 1_024
|
||||
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
|
||||
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
|
||||
const MAX_TOOL_CALLS_PER_RUN = 100
|
||||
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
|
||||
const MAX_QUESTIONS_PER_REQUEST = 4
|
||||
const MAX_QUESTION_OPTIONS = 20
|
||||
const EMBEDDED_SERVER_USERNAME = 'goodbuddy'
|
||||
|
||||
type SpawnedProcess = ReturnType<typeof spawn>
|
||||
@@ -230,6 +237,69 @@ function parsePermissionRequest(
|
||||
return properties as PermissionRequest
|
||||
}
|
||||
|
||||
function parseQuestionRequest(
|
||||
properties: unknown,
|
||||
sessionId: string
|
||||
): QuestionRequest | undefined {
|
||||
if (!isRecord(properties) || properties.sessionID !== sessionId) {
|
||||
return undefined
|
||||
}
|
||||
const { id, questions, tool } = properties
|
||||
if (
|
||||
typeof id !== 'string' ||
|
||||
id.length === 0 ||
|
||||
id.length > MAX_PERMISSION_NAME_LENGTH ||
|
||||
!Array.isArray(questions) ||
|
||||
questions.length === 0 ||
|
||||
questions.length > MAX_QUESTIONS_PER_REQUEST ||
|
||||
!questions.every(
|
||||
(question) =>
|
||||
isRecord(question) &&
|
||||
typeof question.question === 'string' &&
|
||||
question.question.trim().length > 0 &&
|
||||
question.question.length <= 2_000 &&
|
||||
typeof question.header === 'string' &&
|
||||
question.header.trim().length > 0 &&
|
||||
question.header.length <= 120 &&
|
||||
Array.isArray(question.options) &&
|
||||
question.options.length <= MAX_QUESTION_OPTIONS &&
|
||||
question.options.every(
|
||||
(option) =>
|
||||
isRecord(option) &&
|
||||
typeof option.label === 'string' &&
|
||||
option.label.trim().length > 0 &&
|
||||
option.label.length <= 200 &&
|
||||
typeof option.description === 'string' &&
|
||||
option.description.length <= 1_000
|
||||
) &&
|
||||
(question.multiple === undefined ||
|
||||
typeof question.multiple === 'boolean') &&
|
||||
(question.custom === undefined ||
|
||||
typeof question.custom === 'boolean')
|
||||
) ||
|
||||
(tool !== undefined &&
|
||||
(!isRecord(tool) ||
|
||||
typeof tool.messageID !== 'string' ||
|
||||
tool.messageID.length === 0 ||
|
||||
tool.messageID.length > 256 ||
|
||||
typeof tool.callID !== 'string' ||
|
||||
tool.callID.length === 0 ||
|
||||
tool.callID.length > 256))
|
||||
) {
|
||||
throw new Error('OpenCode 提问请求格式无效')
|
||||
}
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = JSON.stringify(properties)
|
||||
} catch {
|
||||
throw new Error('OpenCode 提问请求无法序列化')
|
||||
}
|
||||
if (!byteLengthWithin(serialized, MAX_QUESTION_REQUEST_BYTES)) {
|
||||
throw new Error('OpenCode 提问请求超过安全限制')
|
||||
}
|
||||
return properties as QuestionRequest
|
||||
}
|
||||
|
||||
function isSafeTokenCount(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
@@ -355,6 +425,14 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
string,
|
||||
Promise<string>
|
||||
>()
|
||||
private readonly pendingQuestions = new Map<
|
||||
string,
|
||||
{
|
||||
client: OpencodeClient
|
||||
directory: string
|
||||
questionCount: number
|
||||
}
|
||||
>()
|
||||
private embeddedRunTail: Promise<void> = Promise.resolve()
|
||||
private readonly dependencies: OpenCodeRuntimeDependencies
|
||||
|
||||
@@ -904,6 +982,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
>()
|
||||
const reasoningPartIds = new Set<string>()
|
||||
const reportedQuestionIds = new Set<string>()
|
||||
try {
|
||||
const promptText =
|
||||
session.created && request.history?.length
|
||||
@@ -1027,6 +1106,50 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'question.asked' &&
|
||||
event.properties.sessionID === sessionId
|
||||
) {
|
||||
const questionRequest = parseQuestionRequest(
|
||||
event.properties,
|
||||
sessionId
|
||||
)
|
||||
if (
|
||||
questionRequest &&
|
||||
!reportedQuestionIds.has(questionRequest.id)
|
||||
) {
|
||||
reportedQuestionIds.add(questionRequest.id)
|
||||
this.pendingQuestions.set(questionRequest.id, {
|
||||
client,
|
||||
directory,
|
||||
questionCount: questionRequest.questions.length
|
||||
})
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'question',
|
||||
questionId: questionRequest.id,
|
||||
questions: questionRequest.questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description
|
||||
})),
|
||||
multiple: question.multiple ?? false,
|
||||
custom: question.custom ?? true
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(event.type === 'question.replied' ||
|
||||
event.type === 'question.rejected') &&
|
||||
event.properties.sessionID === sessionId
|
||||
) {
|
||||
this.pendingQuestions.delete(event.properties.requestID)
|
||||
}
|
||||
|
||||
if (
|
||||
this.usesEmbeddedPermissionMediation() &&
|
||||
event.type === 'permission.asked'
|
||||
@@ -1193,6 +1316,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortSession)
|
||||
for (const questionId of reportedQuestionIds) {
|
||||
this.pendingQuestions.delete(questionId)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (knowledgeMcpName) {
|
||||
@@ -1203,7 +1329,39 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async respondToQuestion(
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void> {
|
||||
const pending = this.pendingQuestions.get(questionId)
|
||||
if (!pending) {
|
||||
throw new Error('OpenCode 提问已失效或不存在')
|
||||
}
|
||||
const response = answers
|
||||
? answers.length === pending.questionCount
|
||||
? await pending.client.question.reply({
|
||||
requestID: questionId,
|
||||
directory: pending.directory,
|
||||
answers
|
||||
})
|
||||
: undefined
|
||||
: await pending.client.question.reject({
|
||||
requestID: questionId,
|
||||
directory: pending.directory
|
||||
})
|
||||
if (!response) {
|
||||
throw new Error('OpenCode 提问回答数量不匹配')
|
||||
}
|
||||
if (response.error || response.data !== true) {
|
||||
throw new Error(
|
||||
answers ? 'OpenCode 提交回答失败' : 'OpenCode 取消提问失败'
|
||||
)
|
||||
}
|
||||
this.pendingQuestions.delete(questionId)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.pendingQuestions.clear()
|
||||
const startingChild = this.startingChild
|
||||
this.startingChild = undefined
|
||||
if (startingChild) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
AgentRequest,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
@@ -167,6 +168,20 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
await this.current.runtime.releaseConversation?.(conversationId)
|
||||
}
|
||||
|
||||
async respondToQuestion(
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void> {
|
||||
if (this.closing) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const runtime = this.current.runtime
|
||||
if (!runtime.respondToQuestion) {
|
||||
throw new Error('当前 Runtime 不支持回答交互式问题')
|
||||
}
|
||||
await runtime.respondToQuestion(questionId, answers)
|
||||
}
|
||||
|
||||
private retire(slot: RuntimeSlot): Promise<void> {
|
||||
slot.retiring = true
|
||||
if (!slot.disposal) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
AgentEvent,
|
||||
AgentQuestionAnswer,
|
||||
AgentRequest,
|
||||
AgentRuntimeStatus
|
||||
} from '../../shared/contracts'
|
||||
@@ -57,6 +58,10 @@ export interface AgentRuntime {
|
||||
signal: AbortSignal,
|
||||
authorize?: RuntimeAuthorizer
|
||||
): AsyncGenerator<RuntimeEvent, void, void>
|
||||
respondToQuestion?(
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void>
|
||||
releaseConversation?(conversationId: string): Promise<void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -71,6 +71,44 @@ describe('SelectedRuntimeManager', () => {
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('isolates cached runtimes by effective project workspace', async () => {
|
||||
const first = runtime()
|
||||
const second = runtime()
|
||||
const create = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first.value)
|
||||
.mockResolvedValueOnce(second.value)
|
||||
const manager = new SelectedRuntimeManager(create)
|
||||
const selection = { provider: 'opencode' as const }
|
||||
|
||||
const projectOne = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
)
|
||||
const projectOneAgain = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
)
|
||||
const projectTwo = await manager.getRuntime(
|
||||
selection,
|
||||
'C:\\Projects\\Two'
|
||||
)
|
||||
|
||||
expect(projectOneAgain).toBe(projectOne)
|
||||
expect(projectTwo).not.toBe(projectOne)
|
||||
expect(create).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
selection,
|
||||
'C:\\Projects\\One'
|
||||
)
|
||||
expect(create).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
selection,
|
||||
'C:\\Projects\\Two'
|
||||
)
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('retires cached runtimes when settings change', async () => {
|
||||
const first = runtime()
|
||||
const second = runtime()
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { AgentRuntime } from './runtime'
|
||||
import { AgentRuntimeController } from './runtime-controller'
|
||||
|
||||
export type SelectedRuntimeResolver = {
|
||||
getRuntime(selection: AgentRuntimeSelection): Promise<AgentRuntime>
|
||||
getRuntime(
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
): Promise<AgentRuntime>
|
||||
getStatus(
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<AgentRuntimeStatus>
|
||||
@@ -15,6 +18,7 @@ export type SelectedRuntimeResolver = {
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<AgentRuntimeStatus>
|
||||
releaseConversation(conversationId: string): Promise<void>
|
||||
reset?(): Promise<void>
|
||||
}
|
||||
|
||||
export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
@@ -28,28 +32,35 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
|
||||
constructor(
|
||||
private readonly createRuntime: (
|
||||
selection: AgentRuntimeSelection
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
) => Promise<AgentRuntime>
|
||||
) {}
|
||||
|
||||
async getRuntime(
|
||||
selection: AgentRuntimeSelection
|
||||
selection: AgentRuntimeSelection,
|
||||
workspacePath?: string
|
||||
): Promise<AgentRuntime> {
|
||||
if (this.disposed) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const key = agentRuntimeSelectionKey(selection)
|
||||
const key = JSON.stringify([
|
||||
agentRuntimeSelectionKey(selection),
|
||||
workspacePath ?? ''
|
||||
])
|
||||
const existing = this.entries.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const operation = this.createRuntime(selection).then(async (runtime) => {
|
||||
if (this.disposed || this.entries.get(key) !== operation) {
|
||||
await runtime.dispose()
|
||||
throw new Error('Runtime 设置已更改,请重新选择')
|
||||
const operation = this.createRuntime(selection, workspacePath).then(
|
||||
async (runtime) => {
|
||||
if (this.disposed || this.entries.get(key) !== operation) {
|
||||
await runtime.dispose()
|
||||
throw new Error('Runtime 设置已更改,请重新选择')
|
||||
}
|
||||
return new AgentRuntimeController(runtime)
|
||||
}
|
||||
return new AgentRuntimeController(runtime)
|
||||
})
|
||||
)
|
||||
this.entries.set(key, operation)
|
||||
try {
|
||||
return await operation
|
||||
|
||||
Reference in New Issue
Block a user