fix: reuse runtime processes across project sessions
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 需配套更新。
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import type { RuntimeAcpProcessOutput, RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
|
||||
describe('AgentAcpConnection', () => {
|
||||
it('uses one decoder and handshake and routes interleaved Sessions without closing peers', async () => {
|
||||
let output!: (value: RuntimeAcpProcessOutput) => void | Promise<void>
|
||||
let input!: ReadableStreamDefaultController<Uint8Array>
|
||||
const unsubscribe = vi.fn()
|
||||
const process: RuntimeAcpProcessOwner = {
|
||||
identity: { launchId: 'launch', processId: 'process', supervisorIdentityDigest: `sha256:${'a'.repeat(64)}` },
|
||||
beginPrompt: vi.fn(), completePrompt: vi.fn(), stop: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
subscribeOutput: vi.fn(listener => { output = listener; return unsubscribe }),
|
||||
subscribeExit: vi.fn(() => unsubscribe),
|
||||
writeStdin: async data => input.enqueue(data)
|
||||
}
|
||||
const initialize = vi.fn(async () => ({ protocolVersion: PROTOCOL_VERSION }))
|
||||
const server = new AgentSideConnection(() => ({
|
||||
initialize, newSession: async () => ({ sessionId: 'session' }),
|
||||
prompt: async () => ({ stopReason: 'end_turn' as const }),
|
||||
cancel: async () => undefined
|
||||
}), ndJsonStream(new WritableStream({
|
||||
write: async data => { await output({ stream: 'stdout', data }) }
|
||||
}), new ReadableStream({ start: controller => { input = controller } })))
|
||||
const transport = new AgentAcpConnection(process)
|
||||
const one: Client = { sessionUpdate: vi.fn(), requestPermission: vi.fn(async () => ({
|
||||
outcome: { outcome: 'selected', optionId: 'allow' }
|
||||
})) }
|
||||
const two: Client = { sessionUpdate: vi.fn(), requestPermission: vi.fn(async () => ({
|
||||
outcome: { outcome: 'cancelled' }
|
||||
})) }
|
||||
const release = transport.register('one', one)
|
||||
transport.register('two', two)
|
||||
try {
|
||||
await Promise.all([1, 2].map(() => transport.initialize({ protocolVersion: PROTOCOL_VERSION })))
|
||||
expect(initialize).toHaveBeenCalledOnce()
|
||||
for (const sessionId of ['one', 'two', 'one', 'two']) {
|
||||
await server.sessionUpdate({
|
||||
sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: sessionId } }
|
||||
})
|
||||
}
|
||||
await vi.waitFor(() => expect(two.sessionUpdate).toHaveBeenCalledTimes(2))
|
||||
expect(one.sessionUpdate).toHaveBeenCalledTimes(2)
|
||||
const permission = (sessionId: string) => server.requestPermission({
|
||||
sessionId, options: [{ kind: 'allow_once', optionId: 'allow', name: 'Allow' }],
|
||||
toolCall: { toolCallId: 'tool', title: 'write', kind: 'edit' }
|
||||
})
|
||||
expect(await permission('one')).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
expect(await permission('two')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
release()
|
||||
expect(await permission('one')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
expect(await permission('two')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
expect(process.subscribeOutput).toHaveBeenCalledOnce()
|
||||
expect(unsubscribe).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
transport.dispose()
|
||||
input.close()
|
||||
}
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
type Client,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
|
||||
/** One decoder and handshake per native process, with Session-scoped clients. */
|
||||
export class AgentAcpConnection {
|
||||
readonly connection: ClientSideConnection
|
||||
readonly exited: Promise<never>
|
||||
private initialization?: Promise<InitializeResponse>
|
||||
private readonly sessions = new Map<string, Client>()
|
||||
private readonly unsubscribeOutput: () => void
|
||||
private readonly unsubscribeExit: () => void
|
||||
private closed = false
|
||||
private closeInput?: () => void
|
||||
private bridgeOrigin?: string
|
||||
|
||||
constructor(
|
||||
process: RuntimeAcpProcessOwner,
|
||||
fallback?: () => Client,
|
||||
createConnection?: (client: () => Client, stream: ReturnType<typeof ndJsonStream>) => ClientSideConnection
|
||||
) {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let rejectExit!: (error: Error) => void
|
||||
this.exited = new Promise<never>((_resolve, reject) => { rejectExit = reject })
|
||||
void this.exited.catch(() => undefined)
|
||||
const input = new ReadableStream<Uint8Array>({
|
||||
start(value) { controller = value }
|
||||
})
|
||||
this.closeInput = () => {
|
||||
controller?.close()
|
||||
controller = undefined
|
||||
}
|
||||
this.unsubscribeOutput = process.subscribeOutput((output) => {
|
||||
if (!this.closed && output.stream === 'stdout' && output.data.byteLength > 0) {
|
||||
controller?.enqueue(output.data.slice())
|
||||
}
|
||||
}) ?? (() => undefined)
|
||||
this.unsubscribeExit = process.subscribeExit?.(() => {
|
||||
if (this.closed) return
|
||||
const error = new Error('ACP Runtime process exited')
|
||||
controller?.error(error)
|
||||
controller = undefined
|
||||
rejectExit(error)
|
||||
}) ?? (() => undefined)
|
||||
const output = new WritableStream<Uint8Array>({
|
||||
write: async (data) => { await process.writeStdin(data) }
|
||||
})
|
||||
const client = (): Client => ({
|
||||
sessionUpdate: async (notification) => {
|
||||
await (this.sessions.get(notification.sessionId) ?? fallback?.())
|
||||
?.sessionUpdate(notification)
|
||||
},
|
||||
requestPermission: async (request) =>
|
||||
await (this.sessions.get(request.sessionId) ?? fallback?.())
|
||||
?.requestPermission(request) ?? { outcome: { outcome: 'cancelled' } },
|
||||
extMethod: async () => ({}),
|
||||
extNotification: async (method, params) => {
|
||||
if (
|
||||
method === 'goodbuddy/modelBridgeReady' &&
|
||||
typeof params.origin === 'string' &&
|
||||
/^http:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9_-]{43}$/u.test(params.origin)
|
||||
) this.bridgeOrigin = params.origin
|
||||
}
|
||||
})
|
||||
const stream = ndJsonStream(output, input)
|
||||
this.connection = createConnection?.(client, stream) ?? new ClientSideConnection(client, stream)
|
||||
}
|
||||
|
||||
initialize(request: InitializeRequest): Promise<InitializeResponse> {
|
||||
return this.initialization ??= this.connection.initialize(request)
|
||||
}
|
||||
|
||||
register(sessionId: string, client: Client): () => void {
|
||||
if (this.sessions.has(sessionId)) throw new Error('ACP Session already has an owner')
|
||||
this.sessions.set(sessionId, client)
|
||||
return () => {
|
||||
if (this.sessions.get(sessionId) === client) this.sessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async setModelRoute(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
socketPath?: string,
|
||||
workMode?: 'ask' | 'execute'
|
||||
): Promise<void> {
|
||||
if (!this.bridgeOrigin) throw new Error('Shared model bridge is not ready')
|
||||
const response = await fetch(`${this.bridgeOrigin}/session`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, operationId, ...(socketPath ? { socketPath, workMode } : { release: true }) }),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`Shared model bridge route failed (${response.status})`)
|
||||
}
|
||||
dispose(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.sessions.clear()
|
||||
this.unsubscribeOutput()
|
||||
this.unsubscribeExit()
|
||||
this.closeInput?.()
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,64 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentOwnedAcpPrompt', () => {
|
||||
it('applies each prompt mode to the retained session and its permission decisions', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-'))
|
||||
temporary.push(root)
|
||||
const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite'))
|
||||
let client!: Client
|
||||
let finish!: (response: PromptResponse) => void
|
||||
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
|
||||
const prepareSession = vi.fn(async () => undefined)
|
||||
const completePrompt = vi.fn(async () => undefined)
|
||||
const owner = new AgentOwnedAcpPrompt({
|
||||
bindingId: 'binding-1', controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace', process: new MemoryProcess(),
|
||||
transcript, prepareSession, completePrompt,
|
||||
createConnection: factory => {
|
||||
client = factory()
|
||||
return {
|
||||
initialize: async () => ({ protocolVersion: 1, agentCapabilities: {} }),
|
||||
newSession,
|
||||
prompt: () => new Promise<PromptResponse>(resolve => { finish = resolve })
|
||||
} as unknown as ClientSideConnection
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (const [index, workMode] of (['execute', 'ask', 'execute'] as const).entries()) {
|
||||
const operationId = `operation-${index}`
|
||||
transcript.prepare({
|
||||
bindingId: 'binding-1', operationId, requestId: operationId,
|
||||
controllerId: 'controller-1', preparationDigest: `sha256:${'a'.repeat(64)}`,
|
||||
promptSequence: index
|
||||
})
|
||||
await owner.start({
|
||||
bindingId: 'binding-1', operationId, requestId: operationId,
|
||||
prompt: [{ type: 'text', text: 'Continue with the selected mode' }]
|
||||
}, workMode)
|
||||
expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId, workMode)
|
||||
const result = await client.requestPermission!({
|
||||
sessionId: 'session-1',
|
||||
toolCall: { toolCallId: `tool-${index}`, kind: 'execute', title: 'Run a test' },
|
||||
options: [
|
||||
{ kind: 'allow_once', optionId: 'allow', name: 'Allow' },
|
||||
{ kind: 'reject_once', optionId: 'reject', name: 'Reject' }
|
||||
]
|
||||
})
|
||||
expect(result.outcome).toEqual({
|
||||
outcome: 'selected', optionId: workMode === 'execute' ? 'allow' : 'reject'
|
||||
})
|
||||
finish({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => expect(transcript.attach('binding-1', operationId, 'controller-1'))
|
||||
.toMatchObject({ state: 'completed' }))
|
||||
}
|
||||
expect(newSession).toHaveBeenCalledOnce()
|
||||
expect(completePrompt).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
owner.close()
|
||||
transcript.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the original prompt promise through Desktop detach and recovers the final response', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
|
||||
temporary.push(root)
|
||||
@@ -76,7 +134,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
expectedModel,
|
||||
process,
|
||||
transcript,
|
||||
@@ -101,7 +158,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text' as const, text: 'finish independently' }]
|
||||
}
|
||||
await expect(owner.start(request)).resolves.toMatchObject({
|
||||
await expect(owner.start(request, 'execute')).resolves.toMatchObject({
|
||||
state: 'running',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
@@ -141,7 +198,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
limit: 10
|
||||
}).events.map((event) => event.kind)
|
||||
).toEqual(['session-update', 'prompt-terminal'])
|
||||
await expect(owner.start(request)).resolves.toMatchObject({
|
||||
await expect(owner.start(request, 'execute')).resolves.toMatchObject({
|
||||
state: 'completed'
|
||||
})
|
||||
expect(prompt).toHaveBeenCalledOnce()
|
||||
@@ -170,7 +227,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt,
|
||||
@@ -191,7 +247,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'finish independently' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
transcript.attach('binding-1', 'operation-1', 'controller-1')
|
||||
@@ -239,7 +295,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process,
|
||||
transcript,
|
||||
completePrompt,
|
||||
@@ -261,7 +316,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'wait for Runtime exit' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await process.emitExit()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
@@ -315,7 +370,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt: async () => undefined,
|
||||
@@ -338,7 +392,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'run one tool' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await expect(
|
||||
client.requestPermission({
|
||||
sessionId: 'session-1',
|
||||
@@ -388,7 +442,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'ask',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt: async () => undefined,
|
||||
@@ -411,7 +464,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'inspect one file' }]
|
||||
})
|
||||
}, 'ask')
|
||||
const permission = (
|
||||
kind: 'read' | 'edit'
|
||||
): Parameters<Client['requestPermission']>[0] => ({
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
RemoteOwnedPromptStartResult
|
||||
} from '../shared/remote-agent-contracts'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import {
|
||||
SemanticPromptStore,
|
||||
SemanticPromptStoreError
|
||||
@@ -25,9 +26,10 @@ export type AgentOwnedAcpPromptOptions = {
|
||||
bindingId: string
|
||||
controllerId: string
|
||||
workspaceDirectory: string
|
||||
workMode: 'ask' | 'execute'
|
||||
expectedModel?: string
|
||||
process: RuntimeAcpProcessOwner
|
||||
transport?: AgentAcpConnection
|
||||
prepareSession?: (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => Promise<void>
|
||||
transcript: SemanticPromptStore
|
||||
completePrompt: (
|
||||
operationId: string,
|
||||
@@ -44,18 +46,18 @@ export type AgentOwnedAcpPromptOptions = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the ACP ClientSideConnection and the original prompt Promise. No
|
||||
* transport-connection signal is part of this lifetime.
|
||||
* Owns a native Session and its original prompt Promise. Desktop connection
|
||||
* signals are not part of this lifetime; the native transport may be shared.
|
||||
*/
|
||||
export class AgentOwnedAcpPrompt {
|
||||
readonly #options: AgentOwnedAcpPromptOptions
|
||||
readonly #connection: ClientSideConnection
|
||||
readonly #input: ReadableStream<Uint8Array>
|
||||
readonly #unsubscribeOutput: () => void
|
||||
readonly #unsubscribeExit: () => void
|
||||
readonly #transport: AgentAcpConnection
|
||||
#unregisterSession?: () => void
|
||||
readonly #processExit: Promise<never>
|
||||
#sessionId?: string
|
||||
#promptPromise?: Promise<void>
|
||||
#promptSettled?: Promise<void>
|
||||
#closed = false
|
||||
#initialized = false
|
||||
readonly #questions = new Map<string, {
|
||||
@@ -64,56 +66,23 @@ export class AgentOwnedAcpPrompt {
|
||||
#acceptQuestions = false
|
||||
#active?: {
|
||||
operationId: string
|
||||
workMode: 'ask' | 'execute'
|
||||
}
|
||||
|
||||
constructor(options: AgentOwnedAcpPromptOptions) {
|
||||
this.#options = options
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let rejectProcessExit!: (error: Error) => void
|
||||
this.#processExit = new Promise<never>((_resolve, reject) => {
|
||||
rejectProcessExit = reject
|
||||
})
|
||||
void this.#processExit.catch(() => undefined)
|
||||
this.#input = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value
|
||||
}
|
||||
})
|
||||
const output = new WritableStream<Uint8Array>({
|
||||
write: async (chunk) => {
|
||||
await options.process.writeStdin(chunk)
|
||||
}
|
||||
})
|
||||
const unsubscribe = options.process.subscribeOutput((event) => {
|
||||
if (
|
||||
event.stream === 'stdout' &&
|
||||
event.data.byteLength > 0 &&
|
||||
!this.#closed
|
||||
) {
|
||||
controller?.enqueue(event.data.slice())
|
||||
}
|
||||
})
|
||||
this.#unsubscribeOutput = unsubscribe ?? (() => undefined)
|
||||
const unsubscribeExit = options.process.subscribeExit?.(() => {
|
||||
if (this.#closed) {
|
||||
return
|
||||
}
|
||||
const error = new Error('ACP Runtime process exited')
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
controller?.error(error)
|
||||
controller = undefined
|
||||
rejectProcessExit(error)
|
||||
})
|
||||
this.#unsubscribeExit = unsubscribeExit ?? (() => undefined)
|
||||
const stream = ndJsonStream(output, this.#input)
|
||||
this.#connection =
|
||||
options.createConnection?.(() => this.#client(), stream) ??
|
||||
new ClientSideConnection(() => this.#client(), stream)
|
||||
this.#transport = options.transport ?? new AgentAcpConnection(
|
||||
options.process, () => this.#client(), options.createConnection
|
||||
)
|
||||
this.#connection = this.#transport.connection
|
||||
this.#processExit = this.#transport.exited
|
||||
}
|
||||
|
||||
get sessionId(): string | undefined { return this.#sessionId }
|
||||
|
||||
async start(
|
||||
request: RemoteOwnedPromptStartRequest
|
||||
request: RemoteOwnedPromptStartRequest,
|
||||
workMode: 'ask' | 'execute'
|
||||
): Promise<RemoteOwnedPromptStartResult> {
|
||||
if (
|
||||
request.bindingId !== this.#options.bindingId
|
||||
@@ -142,7 +111,7 @@ export class AgentOwnedAcpPrompt {
|
||||
|
||||
if (!this.#initialized) {
|
||||
const initialization = await this.#whileProcessAlive(
|
||||
this.#connection.initialize({
|
||||
this.#transport.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
terminal: false,
|
||||
@@ -170,7 +139,7 @@ export class AgentOwnedAcpPrompt {
|
||||
})
|
||||
)
|
||||
} else if (
|
||||
initialization.agentCapabilities?.sessionCapabilities?.resume === true
|
||||
initialization.agentCapabilities?.sessionCapabilities?.resume != null
|
||||
) {
|
||||
await this.#whileProcessAlive(
|
||||
this.#connection.resumeSession({
|
||||
@@ -210,6 +179,7 @@ export class AgentOwnedAcpPrompt {
|
||||
throw new Error('ACP Runtime did not select the prepared model')
|
||||
}
|
||||
}
|
||||
this.#unregisterSession = this.#transport.register(this.#sessionId!, this.#client())
|
||||
this.#initialized = true
|
||||
} else if (
|
||||
request.acpSessionId !== undefined &&
|
||||
@@ -217,8 +187,10 @@ export class AgentOwnedAcpPrompt {
|
||||
) {
|
||||
throw new Error('ACP session identity cannot change within a binding')
|
||||
}
|
||||
await this.#options.prepareSession?.(this.#sessionId!, request.operationId, workMode)
|
||||
this.#active = {
|
||||
operationId: request.operationId
|
||||
operationId: request.operationId,
|
||||
workMode
|
||||
}
|
||||
this.#acceptQuestions = true
|
||||
const begun = this.#options.transcript.begin({
|
||||
@@ -229,13 +201,14 @@ export class AgentOwnedAcpPrompt {
|
||||
})
|
||||
// Keep this exact Promise alive on Agent. The caller receives only the
|
||||
// durable operation identity and may disconnect immediately.
|
||||
this.#promptPromise = this.#whileProcessAlive(
|
||||
const promptOperation = this.#whileProcessAlive(
|
||||
this.#connection.prompt({
|
||||
sessionId: this.#sessionId,
|
||||
sessionId: this.#sessionId!,
|
||||
prompt: request.prompt
|
||||
})
|
||||
)
|
||||
.then(
|
||||
this.#promptSettled = promptOperation.then(() => undefined, () => undefined)
|
||||
this.#promptPromise = promptOperation.then(
|
||||
async (response) => {
|
||||
const proposed =
|
||||
response.stopReason === 'cancelled'
|
||||
@@ -305,6 +278,17 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#clear()
|
||||
}
|
||||
|
||||
async releaseSession(): Promise<void> {
|
||||
await this.cancel()
|
||||
if (this.#sessionId && !this.#closed) {
|
||||
await this.#connection.closeSession({ sessionId: this.#sessionId })
|
||||
}
|
||||
// Wait for the original native Prompt, not its completion callback, which
|
||||
// may be queued behind the caller's backend control operation.
|
||||
await this.#promptSettled
|
||||
this.close()
|
||||
}
|
||||
|
||||
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
|
||||
const pending = this.#questions.get(request.questionId)
|
||||
if (this.#closed || !pending || this.#active?.operationId !== request.operationId ||
|
||||
@@ -384,14 +368,15 @@ export class AgentOwnedAcpPrompt {
|
||||
#handlePermission(
|
||||
request: RequestPermissionRequest
|
||||
): RequestPermissionResponse {
|
||||
const active = this.#requireActive()
|
||||
const selected =
|
||||
this.#options.workMode === 'execute'
|
||||
? request.options.find((option) => option.kind === 'allow_always') ??
|
||||
request.options.find((option) => option.kind === 'allow_once')
|
||||
: request.toolCall.kind === 'read'
|
||||
active.workMode === 'execute'
|
||||
? request.options.find((option) => option.kind === 'allow_once') ??
|
||||
request.options.find((option) => option.kind === 'allow_always')
|
||||
: request.toolCall.kind === 'read' || request.toolCall.kind === 'search'
|
||||
? request.options.find((option) => option.kind === 'allow_once')
|
||||
: request.options.find((option) => option.kind === 'reject_always') ??
|
||||
request.options.find((option) => option.kind === 'reject_once')
|
||||
: request.options.find((option) => option.kind === 'reject_once') ??
|
||||
request.options.find((option) => option.kind === 'reject_always')
|
||||
const response: RequestPermissionResponse =
|
||||
selected === undefined
|
||||
? { outcome: { outcome: 'cancelled' } }
|
||||
@@ -403,7 +388,7 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
this.#options.transcript.append({
|
||||
bindingId: this.#options.bindingId,
|
||||
operationId: this.#requireActive().operationId,
|
||||
operationId: active.operationId,
|
||||
kind: 'permission-decision',
|
||||
payload: {
|
||||
sessionId: request.sessionId,
|
||||
@@ -434,15 +419,15 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#closed = true
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
this.#unsubscribeOutput()
|
||||
this.#unsubscribeExit()
|
||||
this.#unregisterSession?.()
|
||||
if (!this.#options.transport) this.#transport.dispose()
|
||||
}
|
||||
|
||||
async #whileProcessAlive<T>(operation: Promise<T>): Promise<T> {
|
||||
return await Promise.race([operation, this.#processExit])
|
||||
}
|
||||
|
||||
#requireActive(): NonNullable<AgentOwnedAcpPrompt['#active']> {
|
||||
#requireActive(): { operationId: string; workMode: 'ask' | 'execute' } {
|
||||
if (this.#active === undefined) {
|
||||
throw new Error('ACP notification has no active prompt')
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ async function runModelBridgeHelper(
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'shared-sessions',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
requireOptions(options, [
|
||||
@@ -212,6 +213,9 @@ async function runModelBridgeHelper(
|
||||
if (workMode !== 'ask' && workMode !== 'execute') {
|
||||
throw new Error('Invalid model bridge work-mode option')
|
||||
}
|
||||
if (options['shared-sessions'] !== undefined && options['shared-sessions'] !== 'true') {
|
||||
throw new Error('Invalid model bridge shared-sessions option')
|
||||
}
|
||||
return await (
|
||||
dependencies.runModelBridgeHelper ??
|
||||
runOpenCodeModelBridgeHelper
|
||||
@@ -221,6 +225,7 @@ async function runModelBridgeHelper(
|
||||
model: options.model!,
|
||||
supportsImageInput: imageInput === 'true',
|
||||
workMode,
|
||||
...(options['shared-sessions'] === 'true' ? { sharedSessions: true } : {}),
|
||||
opencodeEntrypoint: options['opencode-entrypoint']!
|
||||
})
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ function diagnosticErrorName(error: unknown): string {
|
||||
return error instanceof Error ? 'Error' : 'NonError'
|
||||
}
|
||||
|
||||
const SAFE_DIAGNOSTIC_ERROR_CODES = new Set([
|
||||
const SAFE_DIAGNOSTIC_ERROR_CODES: ReadonlySet<string> = new Set([
|
||||
'EACCES',
|
||||
'EADDRINUSE',
|
||||
'ECONNREFUSED',
|
||||
|
||||
@@ -24,6 +24,32 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('direct Linux stdio Runtime ownership', () => {
|
||||
it('keeps shared stdin available across Session completion without a cumulative prompt quota', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(), profile: profile(), sharedSessions: true,
|
||||
identity: { launchId: 'shared-launch', processId: 'shared-process' },
|
||||
installationId: 'installation-1', registry, platform: 'linux',
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z', maximumInputBytes: 16,
|
||||
spawn: () => { queueMicrotask(() => child.emit('spawn')); return child },
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => identity()
|
||||
})
|
||||
try {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
owner.beginPrompt({ deadlineAt: '2030-01-01T00:00:00.000Z', maximumInputBytes: 16 })
|
||||
await owner.writeStdin(Uint8Array.from(Buffer.from('12345678')))
|
||||
await owner.completePrompt()
|
||||
}
|
||||
await owner.writeStdin(Uint8Array.from(Buffer.from('cancel-peer')))
|
||||
} finally {
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('spawns Ask directly without systemd using detached fixed stdio', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
|
||||
@@ -108,6 +108,7 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
||||
bridgeDirectory: string
|
||||
socketPath: string
|
||||
policy: ModelBridgePolicy
|
||||
sharedSessions?: boolean
|
||||
}
|
||||
}
|
||||
identity: { launchId: string; processId: string }
|
||||
@@ -115,6 +116,7 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
||||
registry: RuntimeOwnerRegistry
|
||||
deadlineAt: string
|
||||
maximumInputBytes: number
|
||||
sharedSessions?: boolean
|
||||
signal?: AbortSignal
|
||||
platform?: NodeJS.Platform
|
||||
spawn?: DirectLinuxStdioSpawn
|
||||
@@ -151,6 +153,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
readonly processIdentity: LinuxRuntimeProcessIdentity
|
||||
|
||||
readonly #child: DirectLinuxStdioChild
|
||||
readonly #sharedSessions: boolean
|
||||
readonly #registry: RuntimeOwnerRegistry
|
||||
readonly #now: () => number
|
||||
readonly #readIdentity: typeof readLinuxRuntimeProcessIdentity
|
||||
@@ -194,6 +197,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
listPidNamespaceMembers: typeof listLinuxPidNamespaceMembers
|
||||
sendSignal: typeof process.kill
|
||||
maximumInputBytes: number
|
||||
sharedSessions?: boolean
|
||||
maximumPendingStdinBytes: number
|
||||
maximumStdinWriteBytes: number
|
||||
maximumOutputBytes: number
|
||||
@@ -208,6 +212,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
this.ownerId = input.ownerId
|
||||
this.processIdentity = Object.freeze({ ...input.processIdentity })
|
||||
this.#child = input.child
|
||||
this.#sharedSessions = input.sharedSessions === true
|
||||
this.#registry = input.registry
|
||||
this.#now = input.now
|
||||
this.#readIdentity = input.readProcessIdentity
|
||||
@@ -222,7 +227,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
input.maximumPromptRuntimeMilliseconds
|
||||
this.#maximumOutputQueueChunks = input.maximumOutputQueueChunks
|
||||
this.#maximumListeners = input.maximumListeners
|
||||
this.#scheduleDeadline(input.deadlineAt)
|
||||
if (!this.#sharedSessions) this.#scheduleDeadline(input.deadlineAt)
|
||||
input.child.stdout.on('data', (chunk: Buffer | Uint8Array) => {
|
||||
this.#acceptOutput(chunk)
|
||||
})
|
||||
@@ -245,13 +250,13 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
if (
|
||||
payload.byteLength > this.#maximumStdinWriteBytes ||
|
||||
this.#inputBytes + payload.byteLength > this.#maximumInputBytes ||
|
||||
(!this.#sharedSessions && this.#inputBytes + payload.byteLength > this.#maximumInputBytes) ||
|
||||
this.#pendingInputBytes + payload.byteLength > this.#maximumPendingStdinBytes
|
||||
) {
|
||||
throw capacityError('Runtime stdin quota reached')
|
||||
}
|
||||
const copy = Buffer.from(payload)
|
||||
this.#inputBytes += copy.byteLength
|
||||
if (!this.#sharedSessions) this.#inputBytes += copy.byteLength
|
||||
this.#pendingInputBytes += copy.byteLength
|
||||
const write = this.#writeTail.then(async () => {
|
||||
if (this.#closed || this.#stdinClosed || !this.#promptActive) {
|
||||
@@ -268,6 +273,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
|
||||
beginPrompt(input: { deadlineAt: string; maximumInputBytes: number }): void {
|
||||
if (this.#sharedSessions && !this.#closed && !this.#stdinClosed) return
|
||||
if (this.#closed || this.#stdinClosed || this.#promptActive) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
this.#promptActive ? 'A Runtime prompt is already active' : 'Runtime process is closed',
|
||||
@@ -293,6 +299,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
|
||||
async completePrompt(): Promise<void> {
|
||||
if (this.#sharedSessions) return
|
||||
if (!this.#promptActive) return
|
||||
await this.#writeTail
|
||||
this.#promptActive = false
|
||||
@@ -655,6 +662,7 @@ export async function launchDirectLinuxStdioProcessOwner(
|
||||
options.listPidNamespaceMembers ?? listLinuxPidNamespaceMembers,
|
||||
sendSignal: options.sendSignal ?? process.kill,
|
||||
maximumInputBytes,
|
||||
sharedSessions: options.sharedSessions,
|
||||
maximumPendingStdinBytes,
|
||||
maximumStdinWriteBytes,
|
||||
maximumOutputBytes: manifest.limits.maximumPromptOutputBytes,
|
||||
@@ -975,7 +983,7 @@ function defaultSpawn(
|
||||
args: readonly string[],
|
||||
options: Parameters<DirectLinuxStdioSpawn>[2]
|
||||
): DirectLinuxStdioChild {
|
||||
return nodeSpawn(executable, [...args], options)
|
||||
return nodeSpawn(executable, [...args], { ...options, stdio: [...options.stdio] })
|
||||
}
|
||||
|
||||
async function waitForSpawn(
|
||||
|
||||
@@ -132,24 +132,27 @@ const FORWARDED_HEADER_NAMES = [
|
||||
|
||||
export class ModelBridgeLoopbackProxy {
|
||||
readonly #exchange: ModelBridgeExchange
|
||||
readonly #sharedSessions: boolean
|
||||
readonly #sessionRoutes = new Map<string, { operationId: string; socketPath: string; workMode: 'ask' | 'execute' }>()
|
||||
readonly #routeToken: string
|
||||
readonly #maximumConnections: number
|
||||
readonly #requestTimeoutMs: number
|
||||
readonly #sockets = new Set<Socket>()
|
||||
readonly #dispatchWaiters: DispatchWaiter[] = []
|
||||
readonly #dispatches = new Map<string, DispatchWaiter[]>()
|
||||
#server?: Server
|
||||
#origin?: string
|
||||
#dispatchBusy = false
|
||||
#closing = false
|
||||
#closePromise?: Promise<void>
|
||||
|
||||
constructor(options: {
|
||||
exchange: ModelBridgeExchange
|
||||
sharedSessions?: boolean
|
||||
routeToken?: string
|
||||
maximumConnections?: number
|
||||
requestTimeoutMs?: number
|
||||
}) {
|
||||
this.#exchange = options.exchange
|
||||
this.#sharedSessions = options.sharedSessions === true
|
||||
this.#routeToken = parseModelBridgeRouteToken(
|
||||
options.routeToken ??
|
||||
randomBytes(32).toString('base64url')
|
||||
@@ -259,6 +262,7 @@ export class ModelBridgeLoopbackProxy {
|
||||
)
|
||||
} finally {
|
||||
this.#sockets.clear()
|
||||
this.#sessionRoutes.clear()
|
||||
this.#closing = false
|
||||
}
|
||||
}
|
||||
@@ -280,6 +284,9 @@ export class ModelBridgeLoopbackProxy {
|
||||
let responseFlushed = false
|
||||
let deliveryAcknowledged = false
|
||||
let dispatchHeld = false
|
||||
let dispatchKey = ''
|
||||
let sessionId: string | undefined
|
||||
let sessionRoute: { operationId: string; socketPath: string } | undefined
|
||||
const onAborted = (): void => {
|
||||
cancellation.abort(
|
||||
new ModelBridgeBrokerError('request-cancelled')
|
||||
@@ -303,14 +310,58 @@ export class ModelBridgeLoopbackProxy {
|
||||
timeout?.unref?.()
|
||||
|
||||
try {
|
||||
if (this.#sharedSessions && incoming.url?.startsWith(`/${this.#routeToken}/session`)) {
|
||||
const url = new URL(incoming.url, 'http://localhost')
|
||||
if (url.pathname !== `/${this.#routeToken}/session`) throw new HttpRequestError(404, 'path-not-allowed')
|
||||
if (incoming.method === 'GET') {
|
||||
const route = this.#sessionRoutes.get(url.searchParams.get('sessionId') ?? '')
|
||||
if (!route) throw new HttpRequestError(409, 'session-inactive')
|
||||
outgoing.setHeader('content-type', 'application/json')
|
||||
outgoing.end(JSON.stringify({ operationId: route.operationId, workMode: route.workMode }))
|
||||
return
|
||||
}
|
||||
if (incoming.method !== 'POST') throw new HttpRequestError(405, 'method-not-allowed')
|
||||
const value = JSON.parse((await readBoundedBody(incoming)).toString('utf8')) as {
|
||||
sessionId?: string; operationId?: string; socketPath?: string; release?: boolean; workMode?: 'ask' | 'execute'
|
||||
}
|
||||
if (
|
||||
typeof value.sessionId !== 'string' || value.sessionId.length > 128 ||
|
||||
typeof value.operationId !== 'string' || value.operationId.length > 128
|
||||
) throw new HttpRequestError(400, 'request-invalid')
|
||||
if (value.release) {
|
||||
if (this.#sessionRoutes.get(value.sessionId)?.operationId === value.operationId) {
|
||||
this.#sessionRoutes.delete(value.sessionId)
|
||||
}
|
||||
} else {
|
||||
if (value.workMode !== 'ask' && value.workMode !== 'execute') throw new HttpRequestError(400, 'request-invalid')
|
||||
const socketPath = normalizedAbsolutePath(value.socketPath ?? '', 'Model bridge socket')
|
||||
this.#sessionRoutes.set(value.sessionId, { operationId: value.operationId, socketPath, workMode: value.workMode })
|
||||
}
|
||||
outgoing.end('{}')
|
||||
return
|
||||
}
|
||||
const request = await parseHttpRequest(
|
||||
incoming,
|
||||
this.#routeToken
|
||||
)
|
||||
await this.#acquireDispatch(cancellation.signal)
|
||||
let exchange = this.#exchange
|
||||
if (this.#sharedSessions) {
|
||||
const sessionHeader = incoming.headers['x-goodbuddy-session']
|
||||
const operationId = incoming.headers['x-goodbuddy-operation']
|
||||
sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined
|
||||
const route = sessionId ? this.#sessionRoutes.get(sessionId) : undefined
|
||||
if (!route || route.operationId !== operationId) throw new HttpRequestError(409, 'session-inactive')
|
||||
sessionRoute = route
|
||||
dispatchKey = `${sessionId}\0${operationId}`
|
||||
exchange = createUnixModelBridgeExchange({ socketPath: route.socketPath })
|
||||
}
|
||||
await this.#acquireDispatch(dispatchKey, cancellation.signal)
|
||||
dispatchHeld = true
|
||||
if (sessionId && this.#sessionRoutes.get(sessionId) !== sessionRoute) {
|
||||
throw new HttpRequestError(409, 'session-inactive')
|
||||
}
|
||||
dispatched = normalizeExchangeResult(
|
||||
await raceWithAbort(this.#exchange(request, {
|
||||
await raceWithAbort(exchange(request, {
|
||||
requestId: `model-${randomUUID()}`,
|
||||
signal: cancellation.signal
|
||||
}), cancellation.signal)
|
||||
@@ -351,34 +402,36 @@ export class ModelBridgeLoopbackProxy {
|
||||
incoming.off('aborted', onAborted)
|
||||
outgoing.off('close', onResponseClose)
|
||||
if (dispatchHeld) {
|
||||
this.#releaseDispatch()
|
||||
this.#releaseDispatch(dispatchKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #acquireDispatch(signal: AbortSignal): Promise<void> {
|
||||
async #acquireDispatch(key: string, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
if (!this.#dispatchBusy) {
|
||||
this.#dispatchBusy = true
|
||||
const queue = this.#dispatches.get(key)
|
||||
if (!queue) {
|
||||
this.#dispatches.set(key, [])
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const waiter: DispatchWaiter = { resolve, reject, signal }
|
||||
waiter.abort = (): void => {
|
||||
const index = this.#dispatchWaiters.indexOf(waiter)
|
||||
const index = queue.indexOf(waiter)
|
||||
if (index >= 0) {
|
||||
this.#dispatchWaiters.splice(index, 1)
|
||||
queue.splice(index, 1)
|
||||
}
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', waiter.abort, { once: true })
|
||||
this.#dispatchWaiters.push(waiter)
|
||||
queue.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
#releaseDispatch(): void {
|
||||
while (this.#dispatchWaiters.length > 0) {
|
||||
const waiter = this.#dispatchWaiters.shift()!
|
||||
#releaseDispatch(key: string): void {
|
||||
const queue = this.#dispatches.get(key)
|
||||
while (queue && queue.length > 0) {
|
||||
const waiter = queue.shift()!
|
||||
if (waiter.abort !== undefined) {
|
||||
waiter.signal.removeEventListener('abort', waiter.abort)
|
||||
}
|
||||
@@ -389,7 +442,7 @@ export class ModelBridgeLoopbackProxy {
|
||||
waiter.resolve()
|
||||
return
|
||||
}
|
||||
this.#dispatchBusy = false
|
||||
this.#dispatches.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +452,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
sharedSessions?: boolean
|
||||
opencodeEntrypoint: string
|
||||
environment?: Readonly<NodeJS.ProcessEnv>
|
||||
spawn?: ModelBridgeHelperSpawn
|
||||
@@ -412,19 +466,19 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
'OpenCode entrypoint'
|
||||
)
|
||||
const exchange = createUnixModelBridgeExchange({ socketPath })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange, sharedSessions: options.sharedSessions })
|
||||
const origin = await proxy.listen()
|
||||
let pluginDirectory: string | undefined
|
||||
try {
|
||||
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
|
||||
const pluginPath = join(pluginDirectory, 'subagent.mjs')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(options.sharedSessions ? origin : undefined), 'utf8')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
loopbackOrigin: origin,
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
workMode: options.workMode
|
||||
workMode: options.sharedSessions ? 'ask' : options.workMode
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
@@ -433,6 +487,11 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
plugin: [pathToFileURL(pluginPath).href]
|
||||
})
|
||||
)
|
||||
if (options.sharedSessions) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'goodbuddy/modelBridgeReady', params: { origin }
|
||||
}) + '\n')
|
||||
}
|
||||
const child = (options.spawn ?? defaultHelperSpawn)(
|
||||
opencodeEntrypoint,
|
||||
['acp'],
|
||||
|
||||
@@ -615,6 +615,50 @@ describe('model bridge loopback helper', () => {
|
||||
describe('model bridge Unix broker', () => {
|
||||
const runOnUnix = process.platform === 'win32' ? it.skip : it
|
||||
|
||||
runOnUnix('routes shared Sessions concurrently and rejects queued work after its operation ends', async () => {
|
||||
let unblock!: () => void
|
||||
const blocked = new Promise<void>(resolve => { unblock = resolve })
|
||||
const dispatchOne = vi.fn(async () => { await blocked; return validResponse })
|
||||
const dispatchTwo = vi.fn(async () => validResponse)
|
||||
const brokers = [dispatchOne, dispatchTwo].map(dispatch => new ModelBridgeBrokerServer({
|
||||
scratchDirectory: privateTemporaryDirectory(), dispatch
|
||||
}))
|
||||
const fallback = vi.fn(async () => validResponse)
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange: fallback, sharedSessions: true })
|
||||
await Promise.all(brokers.map(broker => broker.listen()))
|
||||
const origin = await proxy.listen()
|
||||
const route = (sessionId: string, operationId: string, index: number) => fetch(`${origin}/session`, {
|
||||
method: 'POST', body: JSON.stringify({ sessionId, operationId, socketPath: brokers[index]!.socketPath, workMode: 'execute' })
|
||||
})
|
||||
const prompt = (sessionId: string, operationId: string) => fetch(`${origin}/v1/messages`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', 'x-goodbuddy-session': sessionId, 'x-goodbuddy-operation': operationId },
|
||||
body: '{"model":"fixture"}'
|
||||
})
|
||||
try {
|
||||
expect((await route('one', 'operation-1', 0)).ok).toBe(true)
|
||||
expect((await route('two', 'operation-2', 1)).ok).toBe(true)
|
||||
const first = prompt('one', 'operation-1')
|
||||
await vi.waitFor(() => expect(dispatchOne).toHaveBeenCalledOnce())
|
||||
const queued = prompt('one', 'operation-1')
|
||||
expect((await prompt('two', 'operation-2')).status).toBe(201)
|
||||
await route('one', 'next-operation', 0)
|
||||
unblock()
|
||||
expect((await first).status).toBe(201)
|
||||
expect((await queued).status).toBe(409)
|
||||
expect((await prompt('one', 'operation-1')).status).toBe(409)
|
||||
expect((await prompt('one', 'next-operation')).status).toBe(201)
|
||||
expect(dispatchOne).toHaveBeenCalledTimes(2)
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
for (const dispatch of [dispatchOne, dispatchTwo]) {
|
||||
expect(dispatch.mock.calls[0]?.[0]).not.toHaveProperty('headers.x-goodbuddy-session')
|
||||
}
|
||||
} finally {
|
||||
unblock()
|
||||
await proxy.close()
|
||||
await Promise.all(brokers.map(broker => broker.close()))
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a delayed connection with the default unlimited request timeout', async () => {
|
||||
const socket = new Duplex({
|
||||
read() {},
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
bridgeDirectory: string;
|
||||
socketPath: string;
|
||||
policy: ModelBridgePolicy;
|
||||
sharedSessions?: boolean;
|
||||
};
|
||||
}): OpenCodeLaunchProfile {
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest);
|
||||
@@ -90,6 +91,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
"Model bridge socket",
|
||||
),
|
||||
policy: modelBridgePolicySchema.parse(input.modelBridge.policy),
|
||||
sharedSessions: input.modelBridge.sharedSessions === true,
|
||||
};
|
||||
if (
|
||||
modelBridge !== undefined &&
|
||||
@@ -132,6 +134,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
modelBridge.policy.supportsImageInput ? "true" : "false",
|
||||
"--work-mode",
|
||||
input.workMode,
|
||||
...(modelBridge.sharedSessions ? ["--shared-sessions", "true"] : []),
|
||||
"--opencode-entrypoint",
|
||||
executablePath,
|
||||
],
|
||||
|
||||
@@ -5,6 +5,65 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
|
||||
describe('OpenCode ACP child event plugin', () => {
|
||||
it('attributes child and compaction model rounds to the frozen root operation', async () => {
|
||||
const operations = new Map([['one', 'operation-1'], ['two', 'operation-2']])
|
||||
const fetchRoute = vi.fn(async (url: string) => {
|
||||
const session = new URL(url).searchParams.get('sessionId')!
|
||||
return { ok: operations.has(session), json: async () => ({ operationId: operations.get(session), workMode: 'execute' }) }
|
||||
})
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource('http://127.0.0.1:12345/fixture').replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
|
||||
{ process: { stdout: { write: vi.fn() } }, createServer, randomBytes, fetch: fetchRoute }
|
||||
)
|
||||
const patch = vi.fn()
|
||||
const hooks = await factory({ client: {
|
||||
_client: { get: vi.fn(), post: vi.fn(), patch },
|
||||
session: { get: async ({ path }: { path: { id: string } }) => ({
|
||||
data: {
|
||||
id: path.id, parentID: path.id === 'child' ? 'one' : undefined,
|
||||
permission: path.id === 'child' ? [{ permission: 'task', pattern: '*', action: 'deny' }] : undefined
|
||||
}
|
||||
}) }
|
||||
} })
|
||||
const headers = async (sessionID: string, id: string) => {
|
||||
const output = { headers: {} }
|
||||
await hooks['chat.headers']({ sessionID, message: { id } }, output)
|
||||
return output.headers
|
||||
}
|
||||
try {
|
||||
await hooks['chat.message']({ sessionID: 'one' }, { message: { id: 'first' } })
|
||||
expect(patch).toHaveBeenCalledWith({
|
||||
url: '/session/one', body: { permission: [{ permission: '*', pattern: '*', action: 'allow' }] }, throwOnError: true
|
||||
})
|
||||
expect(await headers('child', 'child-message')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'operation-1'
|
||||
})
|
||||
await hooks['chat.message']({ sessionID: 'child' }, { message: { id: 'child-message' } })
|
||||
expect(patch).toHaveBeenLastCalledWith({
|
||||
url: '/session/child', body: { permission: [
|
||||
{ permission: '*', pattern: '*', action: 'allow' },
|
||||
{ permission: 'task', pattern: '*', action: 'deny' }
|
||||
] }, throwOnError: true
|
||||
})
|
||||
expect(await headers('two', 'compact')).toEqual({
|
||||
'x-goodbuddy-session': 'two', 'x-goodbuddy-operation': 'operation-2'
|
||||
})
|
||||
operations.set('one', 'next-operation')
|
||||
expect(await headers('one', 'first')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'operation-1'
|
||||
})
|
||||
await hooks.event({ event: { type: 'session.status', properties: { sessionID: 'one', status: { type: 'idle' } } } })
|
||||
expect(await headers('one', 'next-message')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'next-operation'
|
||||
})
|
||||
operations.delete('two')
|
||||
await expect(headers('two', 'stale-new-message')).rejects.toThrow('no longer active')
|
||||
expect(await headers('two', 'compact')).toEqual({
|
||||
'x-goodbuddy-session': 'two', 'x-goodbuddy-operation': 'operation-2'
|
||||
})
|
||||
} finally { await hooks.dispose() }
|
||||
})
|
||||
|
||||
it('authenticates native replies, checks live ownership, rejects stale events and closes its endpoint', async () => {
|
||||
const write = vi.fn()
|
||||
const question = { id: 'q', sessionID: 'grandchild', questions: [
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* OpenCode's ACP adapter omits native questions and child sessions. Native
|
||||
* events use ACP metadata; question replies use a process-local capability.
|
||||
*/
|
||||
export function openCodeSubagentPluginSource(): string {
|
||||
export function openCodeSubagentPluginSource(modelBridgeOrigin?: string): string {
|
||||
return `import { createServer } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes })`
|
||||
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes, modelBridgeOrigin: ${JSON.stringify(modelBridgeOrigin)} })`
|
||||
}
|
||||
|
||||
async function plugin(input?: {
|
||||
@@ -13,17 +13,41 @@ async function plugin(input?: {
|
||||
_client: {
|
||||
get(input: { url: string; throwOnError: true }): Promise<{ data: unknown }>
|
||||
post(input: { url: string; body?: unknown; throwOnError: true }): Promise<unknown>
|
||||
patch(input: { url: string; body: unknown; throwOnError: true }): Promise<unknown>
|
||||
}
|
||||
session: { get(input: { path: { id: string }; throwOnError: true }): Promise<{
|
||||
data: { id: string; parentID?: string }
|
||||
data: { id: string; parentID?: string; permission?: Array<{ permission: string; pattern: string; action: string }> }
|
||||
}> }
|
||||
}
|
||||
}, modules?: {
|
||||
createServer: typeof import('node:http').createServer
|
||||
randomBytes: typeof import('node:crypto').randomBytes
|
||||
modelBridgeOrigin?: string
|
||||
}) {
|
||||
const tasks = new Map<string, { sessionId: string; callId: string }>()
|
||||
const pending = new Map<string, { sessionId: string; root: string }>()
|
||||
const modelMessages = new Map<string, { sessionId: string; operationId: string; workMode: 'ask' | 'execute' }>()
|
||||
const modelRoute = async (sessionId: string, messageId: string) => {
|
||||
const key = `${sessionId}\0${messageId}`
|
||||
const previous = modelMessages.get(key)
|
||||
if (previous) return previous
|
||||
if (!input || !modules?.modelBridgeOrigin) return undefined
|
||||
let root = sessionId
|
||||
const seen = new Set<string>()
|
||||
for (;;) {
|
||||
if (seen.has(root)) throw new Error('OpenCode Session parent cycle')
|
||||
seen.add(root)
|
||||
const session = await input.client.session.get({ path: { id: root }, throwOnError: true })
|
||||
if (!session.data.parentID) break
|
||||
root = session.data.parentID
|
||||
}
|
||||
const response = await fetch(`${modules.modelBridgeOrigin}/session?sessionId=${encodeURIComponent(root)}`)
|
||||
if (!response.ok) throw new Error('GoodBuddy model operation is no longer active')
|
||||
const { operationId, workMode } = await response.json() as { operationId: string; workMode: 'ask' | 'execute' }
|
||||
const route = { sessionId: root, operationId, workMode }
|
||||
modelMessages.set(key, route)
|
||||
return route
|
||||
}
|
||||
let endpoint: string | undefined
|
||||
let closed = false
|
||||
let closeServer: (() => void) | undefined
|
||||
@@ -82,8 +106,44 @@ async function plugin(input?: {
|
||||
closed = true
|
||||
pending.clear()
|
||||
tasks.clear()
|
||||
modelMessages.clear()
|
||||
closeServer?.()
|
||||
},
|
||||
'chat.message': async (
|
||||
request: { sessionID: string },
|
||||
output: { message: { id: string } }
|
||||
) => {
|
||||
const route = await modelRoute(request.sessionID, output.message.id)
|
||||
if (!route) return
|
||||
// Native ACP only forwards permissions for its registered root Sessions.
|
||||
// Use native Session rules so child tools use Execute and Ask's
|
||||
// unavailable tools are omitted instead of interrupting the prompt.
|
||||
const permission = route.workMode === 'execute'
|
||||
? [{ permission: '*', pattern: '*', action: 'allow' }]
|
||||
: [
|
||||
{ permission: '*', pattern: '*', action: 'deny' },
|
||||
...['read', 'glob', 'grep', 'list', 'lsp', 'webfetch', 'websearch', 'codesearch', 'question', 'external_directory']
|
||||
.map(permission => ({ permission, pattern: '*', action: 'allow' }))
|
||||
]
|
||||
if (route.sessionId !== request.sessionID) {
|
||||
const child = await input!.client.session.get({ path: { id: request.sessionID }, throwOnError: true })
|
||||
// OpenCode copies parent denies, not parent allows, into children.
|
||||
// Keep its explicit subagent restrictions after the request rules.
|
||||
permission.push(...(child.data.permission ?? []).filter(rule => rule.permission !== '*'))
|
||||
}
|
||||
await input!.client._client.patch({
|
||||
url: `/session/${encodeURIComponent(request.sessionID)}`, body: { permission }, throwOnError: true
|
||||
})
|
||||
},
|
||||
'chat.headers': async (
|
||||
request: { sessionID: string; message: { id: string } },
|
||||
output: { headers: Record<string, string> }
|
||||
) => {
|
||||
const route = await modelRoute(request.sessionID, request.message.id)
|
||||
if (!route) return
|
||||
output.headers['x-goodbuddy-session'] = route.sessionId
|
||||
output.headers['x-goodbuddy-operation'] = route.operationId
|
||||
},
|
||||
event: async ({ event }: { event: {
|
||||
type: string
|
||||
properties: {
|
||||
@@ -105,6 +165,9 @@ async function plugin(input?: {
|
||||
const properties = event.properties
|
||||
if (closed) return
|
||||
if (event.type === 'session.status' && properties.status?.type === 'idle' && properties.sessionID) {
|
||||
for (const [key, route] of modelMessages) {
|
||||
if (route.sessionId === properties.sessionID) modelMessages.delete(key)
|
||||
}
|
||||
for (const [id, question] of pending) {
|
||||
if (question.root !== properties.sessionID && question.sessionId !== properties.sessionID) continue
|
||||
pending.delete(id)
|
||||
|
||||
@@ -74,7 +74,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c
|
||||
controllerId: 'controller', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
|
||||
let complete = false
|
||||
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding', controllerId: 'controller',
|
||||
workspaceDirectory: root, workMode: 'execute', transcript,
|
||||
workspaceDirectory: root, transcript,
|
||||
process: {
|
||||
writeStdin: async (data: Uint8Array) => { child.stdin.write(data) },
|
||||
subscribeOutput: (listener: (event: { stream: string; data: Uint8Array }) => void) => {
|
||||
@@ -88,7 +88,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c
|
||||
})
|
||||
try {
|
||||
await owner.start({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
|
||||
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] })
|
||||
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] }, 'execute')
|
||||
let questionId: string | undefined
|
||||
await expect.poll(async () => {
|
||||
const page = transcript.page({ bindingId: 'binding', operationId: 'operation',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
type AcpJournalCursor,
|
||||
type RemotePromptOperationPreparation,
|
||||
@@ -7,7 +10,7 @@ import {
|
||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||
import type { ControllerLease } from './controller-registry'
|
||||
import { AgentModelGatewayError } from './agent-model-gateway'
|
||||
import { EventJournalCapacityError } from './event-journal'
|
||||
import { EventJournal, EventJournalCapacityError } from './event-journal'
|
||||
import type { ProtocolMethodContext } from './protocol-server'
|
||||
import type {
|
||||
ModelBridgeBrokerDispatch,
|
||||
@@ -280,6 +283,8 @@ function harness(input: {
|
||||
uniqueProcesses?: boolean
|
||||
outputGate?: Promise<void>
|
||||
agentOwned?: boolean
|
||||
shareOwnedProcesses?: boolean
|
||||
journal?: RuntimeAcpJournal
|
||||
modelGateway?: {
|
||||
dispatch: ReturnType<typeof vi.fn>
|
||||
finalizePrompt: ReturnType<typeof vi.fn>
|
||||
@@ -319,7 +324,9 @@ function harness(input: {
|
||||
}
|
||||
: undefined
|
||||
const backend = new RuntimeAcpBackend({
|
||||
journal,
|
||||
shareOwnedProcesses: input.shareOwnedProcesses,
|
||||
sharedProcessIdleMs: 20,
|
||||
journal: input.journal ?? journal,
|
||||
resolveRuntimeBundle: vi.fn(async () => resolved),
|
||||
loadRegisteredRuntimeBundle: vi.fn(async () => verifiedBundle()),
|
||||
resolveWorkspace: vi.fn(async (preparation) => ({
|
||||
@@ -444,6 +451,66 @@ function harness(input: {
|
||||
}
|
||||
|
||||
describe('RuntimeAcpBackend', () => {
|
||||
it.each([false, true])('allows mode changes only on a shared Session (shared=%s)', async (shared) => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: shared, workMode: 'execute' })
|
||||
try {
|
||||
await open(fixture)
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation())
|
||||
await invoke(fixture, 'runtime/completePrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1'
|
||||
})
|
||||
const next = fixture.preparation({
|
||||
operationId: 'request-2', requestId: 'request-2', promptSequence: 1, workMode: 'ask'
|
||||
})
|
||||
if (shared) {
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', next)).resolves.toMatchObject({ workMode: 'ask' })
|
||||
await invoke(fixture, 'runtime/completePrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-2', requestId: 'request-2'
|
||||
})
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
operationId: 'request-3', requestId: 'request-3', promptSequence: 2, workMode: 'execute'
|
||||
}))).resolves.toMatchObject({ workMode: 'execute' })
|
||||
} else {
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', next)).rejects.toThrow('work mode cannot change')
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('shares Agent-owned workspaces and modes, retaining peers and reclaiming only an unused process', async () => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: true })
|
||||
const channels: Array<{ bindingId: string; channelId: string; channelEpoch: string }> = []
|
||||
try {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const bindingId = `shared-${index}`
|
||||
const workspaceIdentity = `workspace-${index % 2}`
|
||||
const channel = await invoke(fixture, 'runtime/openAcpChannel', {
|
||||
...fixture.openRequest, bindingId, workspaceIdentity
|
||||
}) as (typeof channels)[number]
|
||||
channels.push({ bindingId, channelId: channel.channelId, channelEpoch: channel.channelEpoch })
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
bindingId, workspaceIdentity, channelEpoch: channel.channelEpoch,
|
||||
workMode: index % 2 ? 'execute' : 'ask',
|
||||
operationId: `operation-${index}`, requestId: `operation-${index}`
|
||||
}))
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
for (const channel of channels.slice(0, 3)) {
|
||||
await invoke(fixture, 'runtime/closeAcpChannel', {
|
||||
...channel, reason: 'released'
|
||||
})
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
await invoke(fixture, 'runtime/closeAcpChannel', { ...channels[3], reason: 'released' })
|
||||
await vi.waitFor(() => expect(fixture.process.stops).toEqual(['binding-closed']))
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects Agent-first unknown blob channels before prompt authority exists', async () => {
|
||||
const fixture = harness()
|
||||
await expect(
|
||||
@@ -1536,6 +1603,71 @@ describe('RuntimeAcpBackend', () => {
|
||||
).rejects.toMatchObject({ code: 'stale-controller' })
|
||||
})
|
||||
|
||||
it('resumes shared semantic bindings with no binary output in the real journal', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-empty-acp-replay-'))
|
||||
const journal = new EventJournal(join(directory, 'events.sqlite'))
|
||||
const fixture = harness({
|
||||
agentOwned: true,
|
||||
shareOwnedProcesses: true,
|
||||
journal
|
||||
})
|
||||
const channels: Array<{
|
||||
bindingId: string
|
||||
channelId: string
|
||||
channelEpoch: string
|
||||
}> = []
|
||||
try {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const bindingId = `empty-${index}`
|
||||
const workspaceIdentity = `workspace-${index % 2}`
|
||||
const channel = await invoke(fixture, 'runtime/openAcpChannel', {
|
||||
...fixture.openRequest, bindingId, workspaceIdentity
|
||||
}) as (typeof channels)[number]
|
||||
channels.push({
|
||||
bindingId,
|
||||
channelId: channel.channelId,
|
||||
channelEpoch: channel.channelEpoch
|
||||
})
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
bindingId, workspaceIdentity,
|
||||
channelEpoch: channel.channelEpoch,
|
||||
operationId: `operation-${index}`,
|
||||
requestId: `operation-${index}`,
|
||||
workMode: index % 2 ? 'execute' : 'ask'
|
||||
}))
|
||||
expect(journal.getAcpCursor(
|
||||
bindingId, channel.channelEpoch, 'runtime-to-main'
|
||||
)).toBeUndefined()
|
||||
}
|
||||
fixture.context.abort.abort()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
const resumed = protocolContext({
|
||||
generation: 2,
|
||||
connectionId: 'connection-2'
|
||||
})
|
||||
for (const channel of channels) {
|
||||
await fixture.backend.methods['runtime/resumeAcpChannel']!(
|
||||
channel, resumed
|
||||
)
|
||||
await expect(fixture.backend.methods['runtime/replayAcpChannel']!(
|
||||
{ ...channel, acknowledgedSequence: '1' }, resumed
|
||||
)).rejects.toThrow('ACK exceeds durable output')
|
||||
await expect(fixture.backend.methods['runtime/replayAcpChannel']!(
|
||||
{ ...channel, acknowledgedSequence: '0' }, resumed
|
||||
)).resolves.toMatchObject({
|
||||
...channel, replayedThroughSequence: '0', live: true
|
||||
})
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
expect(fixture.process.writes).toEqual([])
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
journal.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed on supervised process identity conflict', async () => {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
ProtocolMethodHandler
|
||||
} from './protocol-server'
|
||||
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import type { SemanticPromptStore } from './semantic-prompt-store'
|
||||
import {
|
||||
AgentModelGatewayError,
|
||||
@@ -83,6 +84,7 @@ const DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_GLOBAL =
|
||||
const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
|
||||
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
|
||||
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
|
||||
const SHARED_PROCESS_IDLE_MS = 60_000
|
||||
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
|
||||
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
|
||||
@@ -228,6 +230,7 @@ export type RuntimeAcpProcessLaunch = {
|
||||
workMode: 'ask' | 'execute'
|
||||
deadlineAt: string
|
||||
budget: RemotePromptOperationPreparation['budget']
|
||||
sharedSessions?: boolean
|
||||
modelBridge?: {
|
||||
socketPath: string
|
||||
policy: ModelBridgePolicy
|
||||
@@ -249,6 +252,8 @@ export type RuntimeAcpBackendLimits = {
|
||||
}
|
||||
|
||||
export type RuntimeAcpBackendOptions = {
|
||||
shareOwnedProcesses?: boolean
|
||||
sharedProcessIdleMs?: number
|
||||
journal: RuntimeAcpJournal
|
||||
resolveRuntimeBundle(
|
||||
runtimeId: string,
|
||||
@@ -298,6 +303,7 @@ type PreparedOperation = {
|
||||
budget: RemotePromptOperationPreparation['budget']
|
||||
completion?: z.infer<typeof acpCompletePromptResultSchema>
|
||||
terminalState?: 'completed' | 'failed' | 'cancelled' | 'interrupted'
|
||||
processTree?: 'running' | 'empty'
|
||||
modelProfile?: AgentPromptModelProfile
|
||||
promptSequence: number
|
||||
ownedPromptStarted?: boolean
|
||||
@@ -314,6 +320,7 @@ type BindingState = {
|
||||
state: 'open' | 'running' | 'stopping' | 'closed' | 'outcome-unknown'
|
||||
workMode?: 'ask' | 'execute'
|
||||
process?: RuntimeAcpProcessOwner
|
||||
sharedProcess?: SharedProcess
|
||||
unsubscribeOutput?: () => void
|
||||
unsubscribeExit?: () => void
|
||||
stopRequested: boolean
|
||||
@@ -338,12 +345,23 @@ type BindingState = {
|
||||
poisoned: boolean
|
||||
}
|
||||
|
||||
type SharedProcess = {
|
||||
key: string
|
||||
process: RuntimeAcpProcessOwner
|
||||
transport: AgentAcpConnection
|
||||
bindings: Set<BindingState>
|
||||
unsubscribeExit?: () => void
|
||||
idleTimer?: NodeJS.Timeout
|
||||
exited: boolean
|
||||
}
|
||||
|
||||
export class RuntimeAcpBackend {
|
||||
readonly methods: Readonly<Record<string, ProtocolMethodHandler>>
|
||||
readonly #options: RuntimeAcpBackendOptions
|
||||
readonly #limits: RuntimeAcpBackendLimits
|
||||
readonly #bindings = new Map<string, BindingState>()
|
||||
readonly #processIdentityBindings = new Map<string, string>()
|
||||
readonly #sharedProcesses = new Map<string, SharedProcess>()
|
||||
readonly #boundConnectionSignals = new WeakSet<AbortSignal>()
|
||||
#nextChannelEpoch: bigint
|
||||
#lifetimeBindings = 0
|
||||
@@ -636,6 +654,14 @@ export class RuntimeAcpBackend {
|
||||
result.status === 'rejected'
|
||||
)
|
||||
.map((result) => result.reason)
|
||||
const sharedResults = await Promise.allSettled(
|
||||
[...this.#sharedProcesses.values()].map(group =>
|
||||
this.#stopSharedProcess(group, 'binding-closed')
|
||||
)
|
||||
)
|
||||
for (const result of sharedResults) {
|
||||
if (result.status === 'rejected') failures.push(result.reason)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
@@ -930,11 +956,15 @@ export class RuntimeAcpBackend {
|
||||
'identity'
|
||||
)
|
||||
}
|
||||
this.#options.journal.acknowledgeAcpFromMain?.({
|
||||
bindingId: binding.request.bindingId,
|
||||
channelEpoch: binding.openResult.channelEpoch,
|
||||
sequence: request.acknowledgedSequence
|
||||
})
|
||||
// Agent-owned prompts may have semantic events but no binary journal.
|
||||
// In that case the validated zero cursor has nothing to acknowledge.
|
||||
if (initialCursor !== undefined) {
|
||||
this.#options.journal.acknowledgeAcpFromMain?.({
|
||||
bindingId: binding.request.bindingId,
|
||||
channelEpoch: binding.openResult.channelEpoch,
|
||||
sequence: request.acknowledgedSequence
|
||||
})
|
||||
}
|
||||
if (this.#options.journal.replayAcpFrames === undefined) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Durable ACP replay is unavailable',
|
||||
@@ -1011,7 +1041,8 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
if (
|
||||
binding.workMode !== undefined &&
|
||||
binding.workMode !== preparation.workMode
|
||||
binding.workMode !== preparation.workMode &&
|
||||
binding.sharedProcess === undefined
|
||||
) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Runtime process work mode cannot change',
|
||||
@@ -1124,12 +1155,22 @@ export class RuntimeAcpBackend {
|
||||
|
||||
if (binding.process === undefined) {
|
||||
let process: RuntimeAcpProcessOwner
|
||||
const sharedKey =
|
||||
this.#options.shareOwnedProcesses &&
|
||||
preparation.modelProfile && this.#options.modelGateway &&
|
||||
this.#options.semanticPrompts && binding.modelBridgePolicy
|
||||
? canonicalJson([
|
||||
verified.manifest.bundleDigest,
|
||||
verified.manifest.adapterDigest,
|
||||
binding.modelBridgePolicy
|
||||
])
|
||||
: undefined
|
||||
this.#options.diagnostics?.tryRecord('runtime.starting', {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode
|
||||
})
|
||||
try {
|
||||
process = await this.#options.launchProcess({
|
||||
const launch: RuntimeAcpProcessLaunch = {
|
||||
manifest: verified.manifest,
|
||||
bundle: verified,
|
||||
workspace,
|
||||
@@ -1137,6 +1178,7 @@ export class RuntimeAcpBackend {
|
||||
workMode: preparation.workMode,
|
||||
deadlineAt: preparation.deadlineAt,
|
||||
budget: preparation.budget,
|
||||
...(sharedKey === undefined ? {} : { sharedSessions: true }),
|
||||
...(binding.modelBridgePolicy === undefined ||
|
||||
binding.modelBridgeBroker === undefined
|
||||
? {}
|
||||
@@ -1146,7 +1188,15 @@ export class RuntimeAcpBackend {
|
||||
policy: binding.modelBridgePolicy
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (sharedKey !== undefined) {
|
||||
const group = await this.#acquireSharedProcess(sharedKey, launch)
|
||||
binding.sharedProcess = group
|
||||
group.bindings.add(binding)
|
||||
process = group.process
|
||||
} else {
|
||||
process = await this.#options.launchProcess(launch)
|
||||
}
|
||||
assertProcessIdentity(process.identity)
|
||||
} catch (error) {
|
||||
this.#options.diagnostics?.tryRecord('runtime.start.failed', {
|
||||
@@ -1166,7 +1216,7 @@ export class RuntimeAcpBackend {
|
||||
const identityOwner = this.#processIdentityBindings.get(processKey)
|
||||
if (
|
||||
identityOwner !== undefined &&
|
||||
identityOwner !== binding.request.bindingId
|
||||
identityOwner !== (binding.sharedProcess?.key ?? binding.request.bindingId)
|
||||
) {
|
||||
await Promise.resolve(
|
||||
process.stop({
|
||||
@@ -1182,7 +1232,7 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
this.#processIdentityBindings.set(
|
||||
processKey,
|
||||
binding.request.bindingId
|
||||
binding.sharedProcess?.key ?? binding.request.bindingId
|
||||
)
|
||||
binding.process = process
|
||||
binding.workspaceDirectory = workspace.workspaceDirectory
|
||||
@@ -1208,7 +1258,7 @@ export class RuntimeAcpBackend {
|
||||
binding.unsubscribeOutput = unsubscribe
|
||||
}
|
||||
}
|
||||
const unsubscribeExit = process.subscribeExit?.(() => {
|
||||
const unsubscribeExit = binding.sharedProcess ? undefined : process.subscribeExit?.(() => {
|
||||
void this.#enqueueControl(async () => {
|
||||
await this.#handleProcessExit(binding)
|
||||
}).catch(() => undefined)
|
||||
@@ -1230,7 +1280,7 @@ export class RuntimeAcpBackend {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode
|
||||
})
|
||||
} else {
|
||||
} else if (!binding.sharedProcess) {
|
||||
try {
|
||||
await binding.process.beginPrompt({
|
||||
deadlineAt: preparation.deadlineAt,
|
||||
@@ -1246,6 +1296,7 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.workMode = preparation.workMode
|
||||
binding.inputBytes = 0
|
||||
if (!binding.operations.has(preparation.operationId)) {
|
||||
binding.operations.set(preparation.operationId, {
|
||||
@@ -1389,12 +1440,14 @@ export class RuntimeAcpBackend {
|
||||
'conflict'
|
||||
)
|
||||
}
|
||||
if (Buffer.byteLength(canonicalJson(request.prompt)) > prepared.budget.maximumInputBytes) {
|
||||
throw new RuntimeAcpBackendError('Prompt input quota reached', 'capacity')
|
||||
}
|
||||
if (binding.ownedAcp === undefined) {
|
||||
binding.ownedAcp = new AgentOwnedAcpPrompt({
|
||||
bindingId: binding.request.bindingId,
|
||||
controllerId: binding.controllerId,
|
||||
workspaceDirectory: binding.workspaceDirectory,
|
||||
workMode: binding.workMode!,
|
||||
...(binding.modelBridgePolicy === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -1404,6 +1457,14 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}),
|
||||
process: binding.process,
|
||||
...(binding.sharedProcess ? {
|
||||
transport: binding.sharedProcess.transport,
|
||||
prepareSession: async (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => {
|
||||
await binding.sharedProcess!.transport.setModelRoute(
|
||||
sessionId, operationId, binding.modelBridgeBroker!.socketPath, workMode
|
||||
)
|
||||
}
|
||||
} : {}),
|
||||
transcript: this.#options.semanticPrompts,
|
||||
completePrompt: async (operationId, status) => {
|
||||
await this.#enqueueControl(async () => {
|
||||
@@ -1418,7 +1479,7 @@ export class RuntimeAcpBackend {
|
||||
binding.poisoned ? 'outcome-unknown' : status
|
||||
})
|
||||
}
|
||||
const result = await binding.ownedAcp.start(request)
|
||||
const result = await binding.ownedAcp.start(request, prepared.acceptance.workMode)
|
||||
prepared.ownedPromptStarted = true
|
||||
if (binding.ownedPromptStartTimer !== undefined) {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
@@ -1532,7 +1593,7 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
await this.#closeModelBridge(binding, true)
|
||||
try {
|
||||
await binding.process.completePrompt()
|
||||
if (!binding.sharedProcess) await binding.process.completePrompt()
|
||||
} catch {
|
||||
binding.state = 'outcome-unknown'
|
||||
prepared.modelProfile = undefined
|
||||
@@ -1556,6 +1617,7 @@ export class RuntimeAcpBackend {
|
||||
: status === 'failed'
|
||||
? 'failed'
|
||||
: 'completed'
|
||||
prepared.processTree = 'running'
|
||||
if (status === 'completed') {
|
||||
prepared.completion = acpCompletePromptResultSchema.parse({
|
||||
bindingId: binding.request.bindingId,
|
||||
@@ -1759,6 +1821,11 @@ export class RuntimeAcpBackend {
|
||||
binding.modelBridgeBroker = undefined
|
||||
binding.modelBridgeClient = undefined
|
||||
let brokerError: unknown
|
||||
if (binding.sharedProcess && binding.ownedAcp?.sessionId && binding.activeOperationId) {
|
||||
await binding.sharedProcess.transport.setModelRoute(
|
||||
binding.ownedAcp.sessionId, binding.activeOperationId
|
||||
).catch(() => undefined)
|
||||
}
|
||||
try {
|
||||
await broker?.close()
|
||||
} catch (error) {
|
||||
@@ -1902,7 +1969,7 @@ export class RuntimeAcpBackend {
|
||||
status: 'terminal',
|
||||
terminalState: prepared.terminalState,
|
||||
processTree:
|
||||
prepared.terminalState === 'completed' ? 'running' : 'empty'
|
||||
prepared.processTree ?? (prepared.terminalState === 'completed' ? 'running' : 'empty')
|
||||
}
|
||||
}
|
||||
if (binding.activeOperationId !== operation.operationId) {
|
||||
@@ -2113,6 +2180,25 @@ export class RuntimeAcpBackend {
|
||||
this.#finishBinding(binding, 'interrupted')
|
||||
return
|
||||
}
|
||||
if (binding.sharedProcess) {
|
||||
const group = binding.sharedProcess
|
||||
const prepared = binding.activeOperationId
|
||||
? binding.operations.get(binding.activeOperationId) : undefined
|
||||
try {
|
||||
await rejectAfter(
|
||||
binding.ownedAcp?.releaseSession() ?? Promise.resolve(),
|
||||
OWNED_PROMPT_CANCEL_GRACE_MS * 3,
|
||||
'Shared Runtime Session release timed out'
|
||||
)
|
||||
if (prepared) prepared.processTree = group.exited ? 'empty' : 'running'
|
||||
this.#finishBinding(binding, reason === 'user-cancelled' ? 'cancelled' : 'interrupted')
|
||||
} catch {
|
||||
// A non-responsive native control plane affects the whole process.
|
||||
// Ordinary Session cancellation never reaches this fallback.
|
||||
await this.#stopSharedProcess(group, reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
binding.state = 'stopping'
|
||||
if (!binding.stopRequested) {
|
||||
binding.stopRequested = true
|
||||
@@ -2277,7 +2363,12 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
|
||||
#finishProcess(binding: BindingState): void {
|
||||
if (binding.process !== undefined) {
|
||||
const group = binding.sharedProcess
|
||||
if (group) {
|
||||
group.bindings.delete(binding)
|
||||
binding.sharedProcess = undefined
|
||||
this.#scheduleSharedIdle(group)
|
||||
} else if (binding.process !== undefined) {
|
||||
this.#processIdentityBindings.delete(
|
||||
processIdentityKey(binding.process.identity)
|
||||
)
|
||||
@@ -2305,6 +2396,68 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async #acquireSharedProcess(key: string, launch: RuntimeAcpProcessLaunch): Promise<SharedProcess> {
|
||||
const existing = this.#sharedProcesses.get(key)
|
||||
if (existing && !existing.exited) {
|
||||
if (existing.idleTimer) clearTimeout(existing.idleTimer)
|
||||
existing.idleTimer = undefined
|
||||
return existing
|
||||
}
|
||||
const process = await this.#options.launchProcess(launch)
|
||||
assertProcessIdentity(process.identity)
|
||||
const group: SharedProcess = {
|
||||
key, process, transport: new AgentAcpConnection(process),
|
||||
bindings: new Set(), exited: false
|
||||
}
|
||||
group.unsubscribeExit = process.subscribeExit?.(() => {
|
||||
void this.#enqueueControl(() => this.#handleSharedExit(group)).catch(() => undefined)
|
||||
}) ?? undefined
|
||||
this.#sharedProcesses.set(key, group)
|
||||
return group
|
||||
}
|
||||
|
||||
#scheduleSharedIdle(group: SharedProcess): void {
|
||||
if (group.bindings.size || group.exited || this.#disposed || group.idleTimer) return
|
||||
group.idleTimer = setTimeout(() => {
|
||||
group.idleTimer = undefined
|
||||
void this.#enqueueControl(async () => {
|
||||
if (!group.bindings.size) await this.#stopSharedProcess(group, 'binding-closed')
|
||||
}).catch(() => undefined)
|
||||
}, this.#options.sharedProcessIdleMs ?? SHARED_PROCESS_IDLE_MS)
|
||||
group.idleTimer.unref?.()
|
||||
}
|
||||
|
||||
async #stopSharedProcess(group: SharedProcess, reason: RuntimeAcpProcessStopReason): Promise<void> {
|
||||
if (group.exited) return
|
||||
if (group.idleTimer) clearTimeout(group.idleTimer)
|
||||
group.idleTimer = undefined
|
||||
await group.process.stop({
|
||||
reason, deadlineAt: new Date(this.#now() + 10_000).toISOString()
|
||||
})
|
||||
const result = await group.process.reconcile()
|
||||
if (!sameProcessIdentity(result.identity, group.process.identity) || result.processTree !== 'empty') {
|
||||
throw new RuntimeAcpBackendError('Shared Runtime process stop did not reconcile', 'process')
|
||||
}
|
||||
await this.#handleSharedExit(group)
|
||||
}
|
||||
|
||||
async #handleSharedExit(group: SharedProcess): Promise<void> {
|
||||
if (group.exited) return
|
||||
group.exited = true
|
||||
if (group.idleTimer) clearTimeout(group.idleTimer)
|
||||
group.unsubscribeExit?.()
|
||||
group.transport.dispose()
|
||||
if (this.#sharedProcesses.get(group.key) === group) this.#sharedProcesses.delete(group.key)
|
||||
this.#processIdentityBindings.delete(processIdentityKey(group.process.identity))
|
||||
for (const binding of [...group.bindings]) {
|
||||
if (binding.activeOperationId) {
|
||||
const prepared = binding.operations.get(binding.activeOperationId)
|
||||
if (prepared) prepared.processTree = 'empty'
|
||||
}
|
||||
await this.#handleProcessExit(binding)
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleDeadline(binding: BindingState, deadlineAt: string): void {
|
||||
if (binding.deadlineTimer !== undefined) {
|
||||
clearTimeout(binding.deadlineTimer)
|
||||
|
||||
@@ -245,6 +245,7 @@ export async function createProductionRuntimeProtocol(
|
||||
stateDirectory
|
||||
)
|
||||
backend = new RuntimeAcpBackend({
|
||||
shareOwnedProcesses: true,
|
||||
journal: options.events,
|
||||
resolveRuntimeBundle: (runtimeId, bundleDigest) =>
|
||||
currentRegistry().resolve(
|
||||
@@ -254,7 +255,9 @@ export async function createProductionRuntimeProtocol(
|
||||
),
|
||||
loadRegisteredRuntimeBundle: async (resolved) => {
|
||||
return await loadRegistered(
|
||||
resolved.entry,
|
||||
// This composition's resolver above returns the complete registry
|
||||
// entry; the backend itself only needs its public identity fields.
|
||||
resolved.entry as RuntimeRegistryEntry,
|
||||
resolved.bundleDirectory
|
||||
)
|
||||
},
|
||||
@@ -283,7 +286,8 @@ export async function createProductionRuntimeProtocol(
|
||||
bridgeDirectory:
|
||||
launch.workspace.bridgeDirectory!,
|
||||
socketPath: launch.modelBridge.socketPath,
|
||||
policy: launch.modelBridge.policy
|
||||
policy: launch.modelBridge.policy,
|
||||
sharedSessions: launch.sharedSessions
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -294,7 +298,8 @@ export async function createProductionRuntimeProtocol(
|
||||
installationId,
|
||||
registry: ownerRegistry,
|
||||
deadlineAt: launch.deadlineAt,
|
||||
maximumInputBytes: launch.budget.maximumInputBytes
|
||||
maximumInputBytes: launch.budget.maximumInputBytes,
|
||||
sharedSessions: launch.sharedSessions
|
||||
}),
|
||||
outputSink: options.outputSink,
|
||||
semanticPrompts,
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
AcpRemoteRuntime,
|
||||
RemotePromptCancelledError,
|
||||
RemotePromptRecoveryUnavailableError,
|
||||
type AcpRemoteRuntimeOptions
|
||||
} from './acp-remote-runtime'
|
||||
import type {
|
||||
@@ -2979,6 +2981,42 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the recovered generation for retained and new Sessions and mode changes', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const fresh = ownedChannel()
|
||||
try {
|
||||
await collect(fixture.instance.run(
|
||||
{ ...request, workMode: 'ask' }, new AbortController().signal
|
||||
))
|
||||
Object.defineProperty(fixture.channel, 'generation', { value: 2 })
|
||||
await collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'after-reconnect', workMode: 'execute' },
|
||||
new AbortController().signal
|
||||
))
|
||||
Object.defineProperty(fresh.channel, 'generation', { value: 2 })
|
||||
await collect(fresh.instance.run(
|
||||
{ ...request, requestId: 'new-after-reconnect', conversationId: 'another-conversation' },
|
||||
new AbortController().signal
|
||||
))
|
||||
expect([
|
||||
...fixture.preparePrompt.mock.calls.slice(1),
|
||||
...fresh.preparePrompt.mock.calls
|
||||
].map(([prepared]) => ({
|
||||
controllerGeneration: prepared.controllerGeneration,
|
||||
connectionGeneration: prepared.connectionGeneration
|
||||
}))).toEqual([
|
||||
{ controllerGeneration: 2, connectionGeneration: 2 },
|
||||
{ controllerGeneration: 2, connectionGeneration: 2 }
|
||||
])
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenNthCalledWith(
|
||||
2, expect.objectContaining({ acpSessionId: 'owned-session' })
|
||||
)
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await Promise.all([fixture.instance.dispose(), fresh.instance.dispose()])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not abandon an accepted Agent prompt for a later request', async () => {
|
||||
const fixture = ownedChannel({ waitForCancellation: true })
|
||||
const first = fixture.instance.run(
|
||||
@@ -3115,6 +3153,30 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('definitively ends recovery after the original Agent installation is replaced', async () => {
|
||||
const fixture = ownedChannel({ waitForCancellation: true })
|
||||
const first = fixture.instance.run(request, new AbortController().signal)
|
||||
await first.next()
|
||||
await first.return()
|
||||
const channelFactory = vi.fn(async () => fixture.channel)
|
||||
const replacement = factoryRuntime(channelFactory, fixture.store, {
|
||||
identity: { ...identity, agentInstallationId: 'replacement-agent' },
|
||||
modelBridgePolicy,
|
||||
modelProfile: ownedModelProfile
|
||||
})
|
||||
try {
|
||||
await expect(collect(replacement.run(
|
||||
{ ...request, remoteRecoveryOnly: true },
|
||||
new AbortController().signal
|
||||
))).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
|
||||
expect(channelFactory).not.toHaveBeenCalled()
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await Promise.all([replacement.dispose(), fixture.instance.dispose()])
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles an owned cancellation before the next prompt in the same conversation', async () => {
|
||||
const fixture = ownedChannel()
|
||||
fixture.pageOwnedPromptTranscript.mockImplementationOnce(() => new Promise<never>(() => {}))
|
||||
@@ -3124,7 +3186,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
const pending = stream.next()
|
||||
await vi.waitFor(() => expect(fixture.pageOwnedPromptTranscript).toHaveBeenCalledOnce())
|
||||
controller.abort(new Error('cancel pending question'))
|
||||
await expect(pending).rejects.toThrow('cancel pending question')
|
||||
await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError)
|
||||
expect(await fixture.store.getByConversation(request.conversationId)).toBeUndefined()
|
||||
const events = await collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'after-cancel' }, new AbortController().signal
|
||||
@@ -3170,7 +3232,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(settled).toBe(false)
|
||||
|
||||
releaseEscalation()
|
||||
await expect(pending).rejects.toThrow('cancel owned prompt')
|
||||
await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -138,6 +138,13 @@ export class RemotePromptRecoveryUnavailableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class RemotePromptCancelledError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'RemotePromptCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
type ActivePrompt = {
|
||||
reportedQuestions?: Set<string>
|
||||
subagentProgress?: OpenCodeSubagentProgress
|
||||
@@ -1016,9 +1023,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
false
|
||||
).catch(() => undefined)
|
||||
if (
|
||||
result?.status === 'terminal' &&
|
||||
(result.processTree === 'empty' ||
|
||||
result.terminalState === 'completed')
|
||||
result?.status === 'terminal'
|
||||
) {
|
||||
const terminal = await this.persist(
|
||||
context,
|
||||
@@ -1310,6 +1315,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return runtimeSessionBindingSchema.parse({
|
||||
bindingId,
|
||||
...this.options.identity,
|
||||
controllerGeneration: context.channel.generation,
|
||||
conversationId,
|
||||
runtimeId: this.options.runtimeId,
|
||||
acpSessionId: `opening-${randomUUID()}`,
|
||||
@@ -1353,6 +1359,15 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
'没有可安全附加的远端 Agent 请求,且恢复不会重放任务'
|
||||
)
|
||||
}
|
||||
if (
|
||||
request.remoteRecoveryOnly === true &&
|
||||
binding &&
|
||||
!this.bindingIdentityMatches(binding)
|
||||
) {
|
||||
throw new RemotePromptRecoveryUnavailableError(
|
||||
'原远端 Agent 或 Runtime 已变更,无法恢复原请求且不会重放任务'
|
||||
)
|
||||
}
|
||||
if (
|
||||
request.remoteRecoveryOnly !== true &&
|
||||
this.options.modelProfile !== undefined &&
|
||||
@@ -1741,6 +1756,11 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
current.context,
|
||||
current.binding
|
||||
)
|
||||
current.binding = await this.rotateBindingTransport(
|
||||
current.binding,
|
||||
current.context,
|
||||
signal
|
||||
)
|
||||
current.binding = await this.persist(
|
||||
current.context,
|
||||
current.binding,
|
||||
@@ -2685,9 +2705,10 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
)
|
||||
session.binding = binding
|
||||
if (terminal.state === 'cancelled') {
|
||||
throw signal.aborted
|
||||
? abortReason(signal)
|
||||
: new Error('远端 Runtime 请求已取消')
|
||||
throw new RemotePromptCancelledError(
|
||||
'远端 Runtime 请求已取消',
|
||||
signal.aborted ? { cause: abortReason(signal) } : undefined
|
||||
)
|
||||
}
|
||||
if (terminal.state === 'failed') {
|
||||
throw new Error(
|
||||
@@ -2810,6 +2831,14 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
signal,
|
||||
authorize
|
||||
)
|
||||
} catch (error) {
|
||||
if (signal.aborted && session.binding.state === 'closed') {
|
||||
throw new RemotePromptCancelledError(
|
||||
error instanceof Error ? error.message : '远端 Runtime 请求已取消',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
this.sessionReservations.delete(request.conversationId)
|
||||
this.notifyDrain()
|
||||
|
||||
@@ -134,6 +134,10 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
|
||||
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(
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
type ModelRuntimeOptions
|
||||
} from './model-runtime'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import { OpenCodeRuntime, type OpenCodeRuntimeOptions } from './opencode-runtime'
|
||||
import type { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
type DeepSeekHarnessRuntimeOptions
|
||||
@@ -57,6 +58,7 @@ const noModelTaskTools: ModelToolProviderLike = {
|
||||
}
|
||||
|
||||
export type AgentCapabilityContext = {
|
||||
localRuntimeRegistry?: LocalRuntimeRegistry
|
||||
skillInstructions?: string
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
mcpServers?: ResolvedMcpServer[]
|
||||
@@ -213,8 +215,8 @@ export function createAgentRuntime(
|
||||
if (!capabilities.deepseekHarnessLauncher) {
|
||||
throw new Error('DeepSeek Harness 受控 Host 启动器不可用')
|
||||
}
|
||||
return new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
const options: DeepSeekHarnessRuntimeOptions = {
|
||||
defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace,
|
||||
baseUrl: profile.baseUrl,
|
||||
model: profile.modelName,
|
||||
supportsImageInput: profile.supportsImageInput === true,
|
||||
@@ -225,16 +227,26 @@ export function createAgentRuntime(
|
||||
},
|
||||
skillPackages: capabilities.skillPackages,
|
||||
extensionPackages: capabilities.deepseekHarnessExtensions,
|
||||
toolProvider: new ModelToolProvider(
|
||||
getWorkspaceAccess(),
|
||||
capabilities.mcpServers,
|
||||
undefined,
|
||||
capabilities.knowledgeGateway,
|
||||
capabilities.webSearchEnabled === true,
|
||||
{},
|
||||
capabilities.launchEnvironmentProvider
|
||||
createToolProvider: (directory) => new ModelToolProvider(
|
||||
new LocalWorkspaceAccess(directory), capabilities.mcpServers, undefined,
|
||||
capabilities.knowledgeGateway, capabilities.webSearchEnabled === true,
|
||||
{}, capabilities.launchEnvironmentProvider
|
||||
)
|
||||
})
|
||||
}
|
||||
const create = () => new DeepSeekHarnessRuntime(options)
|
||||
if (!capabilities.localRuntimeRegistry) return create()
|
||||
const configuration = { ...options, defaultWorkspace: undefined, createToolProvider: undefined }
|
||||
return capabilities.localRuntimeRegistry.acquire(
|
||||
`deepseek-harness:${profile.id}`,
|
||||
{
|
||||
...configuration,
|
||||
mcpServers: capabilities.mcpServers,
|
||||
webSearchEnabled: capabilities.webSearchEnabled === true,
|
||||
launchEnvironment: capabilities.launchEnvironmentProvider?.()
|
||||
},
|
||||
workspace,
|
||||
create
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'continue') {
|
||||
@@ -293,7 +305,7 @@ export function createAgentRuntime(
|
||||
'OpenCode 独立模型连接仅支持文本对话协议,不支持图像生成协议'
|
||||
)
|
||||
}
|
||||
return new OpenCodeRuntime({
|
||||
const options: OpenCodeRuntimeOptions = {
|
||||
baseUrl,
|
||||
embedded,
|
||||
binaryPath:
|
||||
@@ -311,13 +323,25 @@ export function createAgentRuntime(
|
||||
skillInstructions: capabilities.skillInstructions,
|
||||
skillPackages: capabilities.skillPackages,
|
||||
sharedCacheRoot: capabilities.opencodeSharedCacheRoot,
|
||||
defaultWorkspace: workspace,
|
||||
defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace,
|
||||
knowledgeGateway: capabilities.knowledgeGateway,
|
||||
mcpServers: capabilities.mcpServers,
|
||||
customization: settings?.runtimeCustomization.opencode,
|
||||
launchEnvironmentProvider:
|
||||
capabilities.launchEnvironmentProvider
|
||||
})
|
||||
}
|
||||
const create = () => new OpenCodeRuntime(options)
|
||||
if (!capabilities.localRuntimeRegistry) return create()
|
||||
const configuration = { ...options, defaultWorkspace: undefined }
|
||||
return capabilities.localRuntimeRegistry.acquire(
|
||||
`opencode:${settings?.opencodeModelProfile?.id ?? 'native'}`,
|
||||
{
|
||||
...configuration,
|
||||
launchEnvironment: capabilities.launchEnvironmentProvider?.()
|
||||
},
|
||||
workspace,
|
||||
create
|
||||
)
|
||||
}
|
||||
|
||||
const defaultModelProfile =
|
||||
|
||||
@@ -37,6 +37,10 @@ import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
|
||||
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
|
||||
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
|
||||
import { SelectedRuntimeManager } from './selected-runtime-manager'
|
||||
import { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import { createAgentRuntime } from './create-runtime'
|
||||
import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
const SKILL_CALL_ID = 'e2e-skill-call'
|
||||
@@ -385,6 +389,81 @@ function createInProcessLaunch(
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
it.each([2, 10])('shares one production-composed Host across %i projects with independent native file and shell tools', async (projectCount) => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-reuse-')))
|
||||
const directories = Array.from(
|
||||
{ length: projectCount }, (_, index) => join(root, `project-${index}`)
|
||||
)
|
||||
await Promise.all(directories.map(async (directory, index) => {
|
||||
await mkdir(directory)
|
||||
await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`)
|
||||
}))
|
||||
const inProcess = createInProcessLaunch(root, {
|
||||
stream(options) {
|
||||
const id = latestUserText(options)
|
||||
const read = toolResultText(options, `${id}-read`)
|
||||
if (read === undefined) return toolCall(`${id}-read`, 'read', { file_path: 'marker.txt' })
|
||||
const write = toolResultText(options, `${id}-write`)
|
||||
if (write === undefined) return toolCall(`${id}-write`, 'write', {
|
||||
file_path: `${id}.txt`, content: read
|
||||
})
|
||||
const shell = toolResultText(options, `${id}-shell`)
|
||||
if (shell === undefined) return toolCall(`${id}-shell`, process.platform === 'win32' ? 'pwsh' : 'bash', {
|
||||
command: process.platform === 'win32' ? 'Get-Content marker.txt' : 'cat marker.txt',
|
||||
description: 'Read the session workspace fixture'
|
||||
})
|
||||
return textResponse(shell)
|
||||
}
|
||||
})
|
||||
const launch = vi.fn(inProcess.launch)
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000009',
|
||||
name: 'Reuse fixture', baseUrl: 'http://127.0.0.1:9',
|
||||
modelName: 'reuse', protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const, apiKey: 'unused-deterministic-model'
|
||||
}
|
||||
const settings = {
|
||||
...defaultRuntimeSettings, provider: 'deepseek-harness',
|
||||
modelProfiles: [profile], deepseekHarnessModelProfile: profile
|
||||
} as ResolvedRuntimeSettings
|
||||
const manager = new SelectedRuntimeManager(async (_selection, space) =>
|
||||
createAgentRuntime(root, settings, {
|
||||
executionSpace: space, localRuntimeRegistry: registry,
|
||||
deepseekHarnessLauncher: launch
|
||||
}), 1, 1, undefined, undefined, (id) => registry.releaseConversation(id))
|
||||
try {
|
||||
const selection = { provider: 'deepseek-harness' as const }
|
||||
for (let wave = 0; wave < 2; wave++) {
|
||||
const outputs = await Promise.all(directories.flatMap((directory, project) =>
|
||||
[0, 1].map(async (conversation) => {
|
||||
const runtime = await manager.getRuntime(selection, resolver.resolveLocal(directory))
|
||||
const id = `p${project}-c${conversation}-w${wave}`
|
||||
const events = await collect(runtime.run({
|
||||
requestId: id, conversationId: `p${project}-c${conversation}`,
|
||||
prompt: id, workMode: 'execute'
|
||||
}, AbortSignal.timeout(20_000)))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
expect(await readFile(join(directory, `${id}.txt`), 'utf8')).toContain(`PROJECT_${project}`)
|
||||
expect(events.filter((event) => event.type === 'text').map((event) => event.delta).join(''))
|
||||
.toContain(`PROJECT_${project}`)
|
||||
return events
|
||||
})
|
||||
))
|
||||
expect(outputs).toHaveLength(projectCount * 2)
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
}
|
||||
await manager.releaseConversation('p0-c0')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
expect(inProcess.hosts).toHaveLength(1)
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
await registry.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
@@ -428,6 +507,103 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native Skills, plugin inventory, MCP calls and images scoped across shared projects', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-shared-capabilities-')))
|
||||
const directories = [join(root, 'one'), join(root, 'two')]
|
||||
await Promise.all(directories.map(directory => mkdir(directory)))
|
||||
const plugin = join(root, 'inventory-plugin.mjs')
|
||||
await writeFile(plugin, [
|
||||
"export const name = 'shared-inventory'",
|
||||
"export const inject = ['skills']",
|
||||
'export function apply(ctx) {',
|
||||
"ctx.skills.register({ name: 'shared-plugin-skill', description: 'Shared plugin fixture', content: '# Shared plugin', source: 'custom' })",
|
||||
'}'
|
||||
].join('\n'))
|
||||
const models = [new FakeGameModel(), new FakeGameModel()]
|
||||
const images = [createCanvas(1, 1).toBuffer('image/png'), createCanvas(2, 1).toBuffer('image/png')]
|
||||
const seenImages = new Map<number, GenerateOptions['messages'][number]['content']>()
|
||||
const inProcess = createInProcessLaunch(root, {
|
||||
stream(options) {
|
||||
const project = latestUserText(options).includes('PROJECT_1') ? 1 : 0
|
||||
seenImages.set(project, options.messages.flatMap(message => message.content).filter(block => block.type === 'image'))
|
||||
return models[project]!.stream(options)
|
||||
}
|
||||
})
|
||||
const launch = vi.fn(inProcess.launch)
|
||||
const calls = vi.spyOn(ModelToolProvider.prototype, 'callTool')
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000010',
|
||||
name: 'Capabilities fixture', baseUrl: 'http://127.0.0.1:9',
|
||||
modelName: 'capabilities', protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const, apiKey: 'unused-deterministic-model',
|
||||
supportsImageInput: true
|
||||
}
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const runtimes = directories.map(directory => createAgentRuntime(root, {
|
||||
...defaultRuntimeSettings, provider: 'deepseek-harness',
|
||||
modelProfiles: [profile], deepseekHarnessModelProfile: profile
|
||||
} as ResolvedRuntimeSettings, {
|
||||
executionSpace: resolver.resolveLocal(directory), localRuntimeRegistry: registry,
|
||||
deepseekHarnessLauncher: launch,
|
||||
skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }],
|
||||
deepseekHarnessExtensions: [{ id: 'shared-inventory', entrypoint: plugin, configuration: {} }],
|
||||
mcpServers: [{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '',
|
||||
enabled: true, allowDynamicTools: false, assignments: ['deepseek-harness'],
|
||||
secretConfigured: false, transport: 'stdio',
|
||||
command: process.versions.electron ? 'node' : process.execPath,
|
||||
args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')]
|
||||
}]
|
||||
}))
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
await Promise.all(runtimes.map(async (runtime, project) => {
|
||||
const events = await collect(runtime.run({
|
||||
requestId: `capabilities-${project}-${mode}`, conversationId: `capabilities-${project}`,
|
||||
workMode: mode,
|
||||
prompt: `${mode === 'ask' ? 'ASK_BOUNDARY_PROBE' : 'Use Skill and MCP'} PROJECT_${project}`,
|
||||
...(mode === 'execute' ? { images: [{
|
||||
name: `project-${project}.png`, mediaType: 'image/png', data: images[project]!.toString('base64')
|
||||
}] } : {})
|
||||
}, AbortSignal.timeout(30_000), async () => 'once'))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
const model = models[project]!
|
||||
if (mode === 'execute') {
|
||||
expect(model.skillResult).toContain('window.__GOODBUDDY_GAME__')
|
||||
expect(model.blueprint).toMatchObject({ title: 'Prism Relay' })
|
||||
const seen = seenImages.get(project)!
|
||||
expect(seen).toHaveLength(1)
|
||||
const image = seen[0]!
|
||||
if (image.type !== 'image') throw new Error('Expected image block')
|
||||
expect(image.attachment).toMatchObject({ width: project + 1, height: 1 })
|
||||
const stored = await inProcess.hosts[0]!.context.attachments.readImage(image.attachment)
|
||||
expect(Buffer.from(stored.data).equals(images[project]!)).toBe(true)
|
||||
} else {
|
||||
expect(model.askToolNames).not.toContain(model.mcpToolName)
|
||||
expect(model.askToolResult).toContain('Ask 模式不允许执行非只读工具')
|
||||
}
|
||||
expect(await runtime.getNativeSnapshot?.()).toMatchObject({
|
||||
skills: expect.arrayContaining([expect.objectContaining({ id: 'shared-plugin-skill', source: 'plugin' })])
|
||||
})
|
||||
}))
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
expect(calls).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
expect(new Set(calls.mock.contexts).size).toBe(2)
|
||||
expect(calls.mock.calls.map(call => call[3]?.conversationId).sort())
|
||||
.toEqual(['capabilities-0', 'capabilities-1'])
|
||||
await runtimes[0]!.releaseConversation?.('capabilities-0')
|
||||
expect(await runtimes[1]!.getStatus()).toMatchObject({ available: true })
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
calls.mockRestore()
|
||||
await Promise.all(runtimes.map(runtime => runtime.dispose()))
|
||||
await registry.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
@@ -780,7 +956,7 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
)
|
||||
|
||||
try {
|
||||
await runtime.getStatus()
|
||||
await runtime.testConnection()
|
||||
expect(inProcess.hosts[0]?.extensionFailures).toEqual([])
|
||||
await expect(runtime.getNativeSnapshot()).resolves.toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
|
||||
@@ -443,6 +443,16 @@ function toolProvider(
|
||||
}
|
||||
|
||||
describe('DeepSeekHarnessRuntime', () => {
|
||||
it('reports passive readiness without launching a Host or reading a model', async () => {
|
||||
const harness = setup()
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true, detail: expect.stringContaining('首次使用')
|
||||
})
|
||||
expect(harness.launch).not.toHaveBeenCalled()
|
||||
await harness.runtime.dispose()
|
||||
expect(harness.launch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes bounded failed-extension cleanup in the startup budget', () => {
|
||||
expect(deepSeekHarnessStartupBudget(11)).toEqual({
|
||||
hostTimeoutMs: 76_000,
|
||||
@@ -474,7 +484,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(10_001)
|
||||
|
||||
@@ -504,7 +514,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(12_000)
|
||||
|
||||
@@ -528,7 +538,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
|
||||
await harness.runtime.dispose()
|
||||
@@ -633,7 +643,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
advertisedImageInput: false
|
||||
})
|
||||
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: expect.stringContaining('图片能力')
|
||||
})
|
||||
@@ -904,11 +914,14 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
])
|
||||
const harness = setup({ toolProvider: provider })
|
||||
await harness.runtime.getStatus()
|
||||
const running = collect(harness.runtime.run(
|
||||
request('catalog', 'execute'), new AbortController().signal
|
||||
))
|
||||
await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1))
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
tools: [
|
||||
@@ -938,10 +951,12 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
expect(
|
||||
JSON.stringify(
|
||||
await harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
)
|
||||
).not.toContain('secret')
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1396,8 +1411,9 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('bounds cancellation when the harness prompt never settles', async () => {
|
||||
const harness = setup({ useProductionPromptTimeout: true })
|
||||
it('releases an uncooperative cancelled session without terminating its peer', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ useProductionPromptTimeout: true, toolProvider: provider })
|
||||
const controller = new AbortController()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
@@ -1409,6 +1425,10 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
const peer = collect(harness.runtime.run(
|
||||
request('cancel-peer'), new AbortController().signal
|
||||
))
|
||||
await vi.waitFor(() => expect(harness.promptGates).toHaveLength(2))
|
||||
controller.abort(new Error('cancelled by user'))
|
||||
|
||||
await expect(running).rejects.toThrow('cancelled by user')
|
||||
@@ -1416,7 +1436,14 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
expect(harness.requests).toContainEqual({
|
||||
method: 'goodbuddy/session/release',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
expect(harness.child.terminate).not.toHaveBeenCalled()
|
||||
expect(provider.releaseConversation).toHaveBeenCalledWith(request('uncooperative-cancel').conversationId)
|
||||
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
|
||||
expect((await peer).at(-1)?.type).toBe('done')
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1447,7 +1474,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
|
||||
it('reports process exit and fully disposes the connection and child', async () => {
|
||||
const harness = setup()
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
harness.exit.resolve({ exitCode: 9 })
|
||||
@@ -1469,7 +1496,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
new Error('method not found')
|
||||
)
|
||||
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: 'method not found'
|
||||
})
|
||||
|
||||
@@ -204,11 +204,13 @@ export type DeepSeekHarnessRuntimeOptions = {
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
extensionPackages?: ControlledHarnessExtensionPackage[]
|
||||
toolProvider?: ModelToolProviderLike
|
||||
createToolProvider?: (workspace: string) => ModelToolProviderLike
|
||||
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
|
||||
}
|
||||
|
||||
type ActiveRun = {
|
||||
request: AgentExecutionRequest
|
||||
toolProvider?: ModelToolProviderLike
|
||||
toolController: AbortController
|
||||
authorize?: RuntimeAuthorizer
|
||||
updates: AcpSessionNotification['update'][]
|
||||
@@ -454,7 +456,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
private disposed = false
|
||||
private fatalError?: Error
|
||||
private stderrBytes = 0
|
||||
private readonly sessions = new Map<string, string>()
|
||||
private readonly sessions = new Map<string, { id: string; workspace: string }>()
|
||||
private readonly workspaceTools = new Map<string, ModelToolProviderLike>()
|
||||
private readonly proxyToolCatalogs = new Map<string, ModelToolDefinition[]>()
|
||||
private readonly sessionInitializations = new Map<
|
||||
string,
|
||||
@@ -832,10 +835,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
'DeepSeek Harness MCP 工具上下文不可用'
|
||||
)
|
||||
}
|
||||
if (!this.options.toolProvider) {
|
||||
return { tools: [] }
|
||||
}
|
||||
const run = this.activeRuns.get(params.sessionId)
|
||||
if (!run?.toolProvider || run.closed) return { tools: [] }
|
||||
const context = {
|
||||
conversationId:
|
||||
run?.request.conversationId ??
|
||||
@@ -848,7 +849,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
knowledgeCapabilityToken:
|
||||
run?.request.knowledgeCapabilityToken
|
||||
}
|
||||
const tools = await this.options.toolProvider.listTools(
|
||||
const tools = await run.toolProvider.listTools(
|
||||
context,
|
||||
connection.signal
|
||||
)
|
||||
@@ -869,7 +870,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
if (
|
||||
!run ||
|
||||
run.closed ||
|
||||
!this.options.toolProvider ||
|
||||
!run.toolProvider ||
|
||||
typeof name !== 'string' ||
|
||||
!argumentsValue ||
|
||||
typeof argumentsValue !== 'object' ||
|
||||
@@ -935,7 +936,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
if (!isWebTool) {
|
||||
const approval =
|
||||
this.options.toolProvider.getApproval(
|
||||
run.toolProvider.getApproval(
|
||||
tool,
|
||||
argumentsValue as Record<string, unknown>,
|
||||
argumentSummary,
|
||||
@@ -950,7 +951,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
)
|
||||
}
|
||||
}
|
||||
const result = await this.options.toolProvider.callTool(
|
||||
const result = await run.toolProvider.callTool(
|
||||
name,
|
||||
argumentsValue as Record<string, unknown>,
|
||||
run.toolController.signal,
|
||||
@@ -1091,6 +1092,35 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
return {
|
||||
id: 'deepseek-harness',
|
||||
label: 'DeepSeek Harness',
|
||||
available: !this.disposed && !this.fatalError,
|
||||
supportsToolExecution: true,
|
||||
detail: this.disposed
|
||||
? 'DeepSeek Harness Runtime 已关闭'
|
||||
: this.fatalError?.message ??
|
||||
(this.state
|
||||
? `DeepSeek Harness ${this.state.capabilities.harnessVersion} · 当前用户权限`
|
||||
: 'DeepSeek Harness 已配置,将在首次使用时启动')
|
||||
}
|
||||
}
|
||||
|
||||
get hasRetainedSessions(): boolean {
|
||||
return this.sessions.size > 0 || this.sessionInitializations.size > 0
|
||||
}
|
||||
|
||||
private toolsFor(workspace: string): ModelToolProviderLike | undefined {
|
||||
if (!this.options.createToolProvider) return this.options.toolProvider
|
||||
let provider = this.workspaceTools.get(workspace)
|
||||
if (!provider) {
|
||||
provider = this.options.createToolProvider(workspace)
|
||||
this.workspaceTools.set(workspace, provider)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
async testConnection(): Promise<AgentRuntimeStatus> {
|
||||
try {
|
||||
await this.getState()
|
||||
return {
|
||||
@@ -1114,10 +1144,13 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async getNativeSnapshot(): Promise<RuntimeNativeSnapshot> {
|
||||
async getNativeSnapshot(workspace?: string): Promise<RuntimeNativeSnapshot> {
|
||||
const state = await this.getState()
|
||||
const response = await withTimeout(
|
||||
state.agent.extMethod(GOODBUDDY_NATIVE_SNAPSHOT, {}),
|
||||
state.agent.extMethod(
|
||||
GOODBUDDY_NATIVE_SNAPSHOT,
|
||||
workspace ? { workspace } : {}
|
||||
),
|
||||
this.initializationTimeoutMs,
|
||||
'原生能力清单'
|
||||
)
|
||||
@@ -1302,11 +1335,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
|
||||
private async getSession(
|
||||
state: HarnessState,
|
||||
conversationId: string
|
||||
conversationId: string,
|
||||
workspace: string
|
||||
): Promise<string> {
|
||||
const current = this.sessions.get(conversationId)
|
||||
if (current?.workspace === workspace) {
|
||||
return current.id
|
||||
}
|
||||
if (current) {
|
||||
return current
|
||||
await this.releaseConversation(conversationId)
|
||||
}
|
||||
const pending = this.sessionInitializations.get(conversationId)
|
||||
if (pending) {
|
||||
@@ -1314,14 +1351,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
const creation = state.agent
|
||||
.newSession({
|
||||
cwd: state.workspace,
|
||||
cwd: workspace,
|
||||
mcpServers: []
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.sessionId) {
|
||||
throw new Error('DeepSeek Harness 未返回 ACP 会话 ID')
|
||||
}
|
||||
this.sessions.set(conversationId, response.sessionId)
|
||||
this.sessions.set(conversationId, { id: response.sessionId, workspace })
|
||||
return response.sessionId
|
||||
})
|
||||
this.sessionInitializations.set(conversationId, creation)
|
||||
@@ -1445,12 +1482,17 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
try {
|
||||
state = await this.getState()
|
||||
signal.throwIfAborted()
|
||||
const workspace = request.executionWorkspace
|
||||
? await realpath(request.executionWorkspace)
|
||||
: state.workspace
|
||||
sessionId = await this.getSession(
|
||||
state,
|
||||
request.conversationId
|
||||
request.conversationId,
|
||||
workspace
|
||||
)
|
||||
run = {
|
||||
request,
|
||||
toolProvider: this.toolsFor(workspace),
|
||||
toolController,
|
||||
authorize,
|
||||
updates: [],
|
||||
@@ -1586,7 +1628,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.shutdownTimeoutMs,
|
||||
'取消请求'
|
||||
).catch(async () => {
|
||||
await this.terminate(state!.child)
|
||||
await withTimeout(
|
||||
state!.agent.extMethod(GOODBUDDY_RELEASE, { sessionId }),
|
||||
this.shutdownTimeoutMs,
|
||||
'释放已取消会话'
|
||||
).catch(async () => {
|
||||
await this.terminate(state!.child)
|
||||
})
|
||||
await this.forgetSession(request.conversationId)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1609,25 +1658,39 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const sessionId = this.sessions.get(conversationId)
|
||||
this.sessions.delete(conversationId)
|
||||
if (sessionId) this.proxyToolCatalogs.delete(sessionId)
|
||||
if (!sessionId || !this.state) {
|
||||
await this.options.toolProvider
|
||||
?.releaseConversation(conversationId)
|
||||
.catch(() => undefined)
|
||||
return
|
||||
await this.sessionInitializations.get(conversationId)?.catch(() => undefined)
|
||||
const session = this.sessions.get(conversationId)
|
||||
const sessionId = session?.id
|
||||
if (sessionId && this.state) {
|
||||
await this.state.agent
|
||||
.extMethod(GOODBUDDY_RELEASE, { sessionId })
|
||||
.catch(async () => {
|
||||
await this.state?.agent
|
||||
.cancel({ sessionId })
|
||||
.catch(() => undefined)
|
||||
})
|
||||
}
|
||||
await this.state.agent
|
||||
.extMethod(GOODBUDDY_RELEASE, { sessionId })
|
||||
.catch(async () => {
|
||||
await this.state?.agent
|
||||
.cancel({ sessionId })
|
||||
.catch(() => undefined)
|
||||
})
|
||||
await this.options.toolProvider
|
||||
await this.forgetSession(conversationId)
|
||||
}
|
||||
|
||||
private async forgetSession(conversationId: string): Promise<void> {
|
||||
const session = this.sessions.get(conversationId)
|
||||
const provider = session
|
||||
? this.workspaceTools.get(session.workspace) ?? this.options.toolProvider
|
||||
: this.options.toolProvider
|
||||
this.sessions.delete(conversationId)
|
||||
if (session) this.proxyToolCatalogs.delete(session.id)
|
||||
await provider
|
||||
?.releaseConversation(conversationId)
|
||||
.catch(() => undefined)
|
||||
if (
|
||||
session && provider &&
|
||||
this.workspaceTools.get(session.workspace) === provider &&
|
||||
![...this.sessions.values()].some((other) => other.workspace === session.workspace)
|
||||
) {
|
||||
this.workspaceTools.delete(session.workspace)
|
||||
await provider.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
@@ -1662,8 +1725,16 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.proxyToolCatalogs.clear()
|
||||
this.sessionInitializations.clear()
|
||||
this.conversationTails.clear()
|
||||
const providers = new Set([
|
||||
...this.workspaceTools.values(),
|
||||
...(this.options.toolProvider ? [this.options.toolProvider] : [])
|
||||
])
|
||||
this.workspaceTools.clear()
|
||||
const disposeTools = () => Promise.allSettled(
|
||||
[...providers].map((provider) => provider.dispose())
|
||||
)
|
||||
if (!state) {
|
||||
await this.options.toolProvider?.dispose().catch(() => undefined)
|
||||
await disposeTools()
|
||||
return
|
||||
}
|
||||
await withTimeout(
|
||||
@@ -1671,7 +1742,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.shutdownTimeoutMs,
|
||||
'内部控制面关闭'
|
||||
).catch(() => undefined)
|
||||
await this.options.toolProvider?.dispose().catch(() => undefined)
|
||||
await disposeTools()
|
||||
await this.terminate(state.child)
|
||||
await withTimeout(
|
||||
Promise.allSettled([
|
||||
|
||||
@@ -926,7 +926,9 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
}
|
||||
|
||||
private async nativeSnapshot(): Promise<Record<string, unknown>> {
|
||||
private async nativeSnapshot(
|
||||
workspace = this.config.workspace
|
||||
): Promise<Record<string, unknown>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
@@ -938,7 +940,7 @@ export class GoodBuddyHarnessControlPlane {
|
||||
try {
|
||||
const skills = await Promise.race([
|
||||
this.ctx.skills.list({
|
||||
cwd: this.config.workspace,
|
||||
cwd: workspace,
|
||||
signal: controller.signal
|
||||
}),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
@@ -1132,12 +1134,11 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
if (
|
||||
!isAbsolute(params.cwd) ||
|
||||
params.cwd !== this.config.workspace ||
|
||||
params.mcpServers.length > 0
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'the controlled workspace and no MCP servers are required'
|
||||
'an absolute workspace and no MCP servers are required'
|
||||
)
|
||||
}
|
||||
const sessionId = SessionId(randomUUID())
|
||||
@@ -1393,7 +1394,11 @@ export class GoodBuddyHarnessControlPlane {
|
||||
return { released: true }
|
||||
}
|
||||
if (method === GOODBUDDY_NATIVE_SNAPSHOT) {
|
||||
return this.nativeSnapshot()
|
||||
const workspace = params.workspace === undefined
|
||||
? this.config.workspace
|
||||
: requiredString(params, 'workspace')
|
||||
if (!isAbsolute(workspace)) throw RequestError.invalidParams(undefined, 'workspace must be absolute')
|
||||
return this.nativeSnapshot(workspace)
|
||||
}
|
||||
if (method === GOODBUDDY_SHUTDOWN) {
|
||||
await this.dispose()
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import type { AgentExecutionRequest, AgentRuntime, RuntimeEvent } from './runtime'
|
||||
|
||||
function fixture() {
|
||||
const requests: AgentExecutionRequest[] = []
|
||||
const sessions = new Set<string>()
|
||||
const runtime: AgentRuntime & { readonly hasRetainedSessions: boolean } = {
|
||||
runtimeId: 'opencode',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
get hasRetainedSessions() { return sessions.size > 0 },
|
||||
getStatus: vi.fn(async () => ({
|
||||
id: 'opencode' as const, label: 'OpenCode', available: true,
|
||||
supportsToolExecution: true, detail: 'ready'
|
||||
})),
|
||||
async *run(request) {
|
||||
requests.push(request)
|
||||
sessions.add(request.conversationId)
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
},
|
||||
releaseConversation: vi.fn(async (id) => { sessions.delete(id) }),
|
||||
dispose: vi.fn(async () => { sessions.clear() })
|
||||
}
|
||||
return { runtime, requests, sessions }
|
||||
}
|
||||
|
||||
async function run(runtime: AgentRuntime, id: string) {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: id, conversationId: id, prompt: 'continue', workMode: 'execute'
|
||||
}, new AbortController().signal)) events.push(event)
|
||||
return events
|
||||
}
|
||||
|
||||
describe('LocalRuntimeRegistry', () => {
|
||||
it('shares the Runtime but fixes each request to its resolved workspace', async () => {
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const host = fixture()
|
||||
const create = vi.fn(() => host.runtime)
|
||||
const first = registry.acquire('opencode:profile', { model: 'one' }, '/first', create)
|
||||
const second = registry.acquire('opencode:profile', { model: 'one' }, '/second', create)
|
||||
await Promise.all([run(first, 'a'), run(second, 'b')])
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(host.requests.map((request) => request.executionWorkspace).sort()).toEqual(['/first', '/second'])
|
||||
await first.dispose()
|
||||
expect(host.runtime.dispose).not.toHaveBeenCalled()
|
||||
await run(second, 'b')
|
||||
await registry.releaseConversation('a')
|
||||
expect(host.sessions).toEqual(new Set(['b']))
|
||||
await registry.dispose()
|
||||
expect(host.runtime.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes a no-session idle host and recreates it through an existing facade', async () => {
|
||||
vi.useFakeTimers()
|
||||
const registry = new LocalRuntimeRegistry(100)
|
||||
const first = fixture()
|
||||
const second = fixture()
|
||||
const create = vi.fn().mockReturnValueOnce(first.runtime).mockReturnValueOnce(second.runtime)
|
||||
try {
|
||||
const facade = registry.acquire('opencode:profile', {}, '/first', create)
|
||||
await facade.getStatus()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(first.runtime.dispose).toHaveBeenCalledOnce()
|
||||
await run(facade, 'a')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(second.runtime.dispose).not.toHaveBeenCalled()
|
||||
await registry.releaseConversation('a')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(second.runtime.dispose).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await registry.dispose()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drains an old configuration without closing a parallel replacement', async () => {
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const old = fixture()
|
||||
let release!: () => void
|
||||
const pending = new Promise<void>((resolve) => { release = resolve })
|
||||
old.runtime.run = async function* (request) {
|
||||
await pending
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
old.runtime.respondToQuestion = vi.fn(async () => undefined)
|
||||
const original = registry.acquire('opencode:profile', { model: 'old' }, '/one', () => old.runtime)
|
||||
const active = run(original, 'a')
|
||||
await Promise.resolve()
|
||||
const next = fixture()
|
||||
const replacement = registry.acquire('opencode:profile', { model: 'new' }, '/two', () => next.runtime)
|
||||
expect(old.runtime.dispose).not.toHaveBeenCalled()
|
||||
await original.respondToQuestion?.('active-question', [])
|
||||
expect(old.runtime.respondToQuestion).toHaveBeenCalledWith('active-question', [])
|
||||
await run(replacement, 'b')
|
||||
release()
|
||||
await active
|
||||
await vi.waitFor(() => expect(old.runtime.dispose).toHaveBeenCalledOnce())
|
||||
expect(next.runtime.dispose).not.toHaveBeenCalled()
|
||||
await registry.dispose()
|
||||
})
|
||||
|
||||
it('does not renew or reacquire an idle owner during passive status polling', async () => {
|
||||
vi.useFakeTimers()
|
||||
const registry = new LocalRuntimeRegistry(100)
|
||||
const host = fixture()
|
||||
const inspection = fixture()
|
||||
const restarted = fixture()
|
||||
const create = vi.fn()
|
||||
.mockReturnValueOnce(host.runtime)
|
||||
.mockReturnValueOnce(inspection.runtime)
|
||||
.mockReturnValueOnce(restarted.runtime)
|
||||
try {
|
||||
const facade = registry.acquire('opencode:profile', {}, '/first', create)
|
||||
for (let poll = 0; poll < 3; poll++) {
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
expect(await facade.getStatus()).toMatchObject({ available: true })
|
||||
}
|
||||
expect(host.runtime.dispose).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(host.runtime.dispose).toHaveBeenCalledOnce()
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
|
||||
await facade.getStatus()
|
||||
expect(inspection.runtime.dispose).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
expect(restarted.runtime.dispose).not.toHaveBeenCalled()
|
||||
|
||||
await run(facade, 'a')
|
||||
expect(create).toHaveBeenCalledTimes(3)
|
||||
expect(restarted.sessions).toEqual(new Set(['a']))
|
||||
} finally {
|
||||
await registry.dispose()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type {
|
||||
AgentExecutionRequest,
|
||||
AgentRuntime,
|
||||
RuntimeAuthorizer,
|
||||
RuntimeEvent
|
||||
} from './runtime'
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
RuntimeConversationCompactInput
|
||||
} from '../../shared/contracts'
|
||||
|
||||
const IDLE_RUNTIME_MS = 60_000
|
||||
|
||||
type LocalProcessRuntime = AgentRuntime & {
|
||||
readonly hasRetainedSessions: boolean
|
||||
}
|
||||
|
||||
class LocalRuntimeEntry {
|
||||
private instance?: LocalProcessRuntime
|
||||
private closing?: Promise<void>
|
||||
private active = 0
|
||||
private retired = false
|
||||
private idle?: ReturnType<typeof setTimeout>
|
||||
readonly description: Pick<AgentRuntime,
|
||||
'runtimeId' | 'requiresToolApproval' | 'supportsToolExecution' |
|
||||
'supportsScopedDataTools' | 'capability'>
|
||||
|
||||
constructor(
|
||||
readonly configuration: unknown,
|
||||
private readonly create: () => LocalProcessRuntime,
|
||||
private readonly idleMs: number,
|
||||
private readonly onClosed: () => void
|
||||
) {
|
||||
const runtime = this.instance = create()
|
||||
this.description = {
|
||||
runtimeId: runtime.runtimeId,
|
||||
requiresToolApproval: runtime.requiresToolApproval,
|
||||
supportsToolExecution: runtime.supportsToolExecution,
|
||||
supportsScopedDataTools: runtime.supportsScopedDataTools,
|
||||
capability: runtime.capability
|
||||
}
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
private clearIdle(): void {
|
||||
if (this.idle) clearTimeout(this.idle)
|
||||
this.idle = undefined
|
||||
}
|
||||
|
||||
private scheduleIdle(): void {
|
||||
this.clearIdle()
|
||||
if (this.active > 0) return
|
||||
if (this.retired) {
|
||||
void this.close().finally(this.onClosed).catch(() => undefined)
|
||||
} else if (!this.instance?.hasRetainedSessions) {
|
||||
this.idle = setTimeout(() => {
|
||||
this.idle = undefined
|
||||
void this.close().catch(() => undefined)
|
||||
}, this.idleMs)
|
||||
this.idle.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
private close(): Promise<void> {
|
||||
this.clearIdle()
|
||||
if (this.closing) return this.closing
|
||||
const runtime = this.instance
|
||||
this.instance = undefined
|
||||
const closing = Promise.resolve().then(() => runtime?.dispose()).finally(() => {
|
||||
if (this.closing === closing) this.closing = undefined
|
||||
})
|
||||
this.closing = closing
|
||||
return closing
|
||||
}
|
||||
|
||||
async enter(): Promise<AgentRuntime> {
|
||||
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
|
||||
this.clearIdle()
|
||||
this.active += 1
|
||||
try {
|
||||
await this.closing
|
||||
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
|
||||
return this.instance ??= this.create()
|
||||
} catch (error) {
|
||||
this.leave()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
leave(): void {
|
||||
this.active -= 1
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
async invoke<T>(operation: (runtime: AgentRuntime) => Promise<T>): Promise<T> {
|
||||
const runtime = await this.enter()
|
||||
try {
|
||||
return await operation(runtime)
|
||||
} finally {
|
||||
this.leave()
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
if (this.retired) throw new Error('Runtime 配置已退役,请重新选择')
|
||||
if (this.instance) return this.instance.getStatus()
|
||||
// Status inspection must not acquire or extend the execution owner's
|
||||
// lifetime. Both local runtimes can inspect configuration without starting.
|
||||
const inspection = this.create()
|
||||
try {
|
||||
return await inspection.getStatus()
|
||||
} finally {
|
||||
await inspection.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async respondToQuestion(questionId: string, answers?: AgentQuestionAnswer[]): Promise<void> {
|
||||
const runtime = this.instance
|
||||
if (!runtime?.respondToQuestion) throw new Error('当前 Runtime 不支持回答交互式问题')
|
||||
// An active request may finish on a retired configuration, including its
|
||||
// outstanding native question. This does not admit a new request.
|
||||
this.clearIdle()
|
||||
this.active += 1
|
||||
try {
|
||||
await runtime.respondToQuestion(questionId, answers)
|
||||
} finally {
|
||||
this.leave()
|
||||
}
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const runtime = this.instance
|
||||
if (!runtime) return
|
||||
this.clearIdle()
|
||||
this.active += 1
|
||||
try {
|
||||
await runtime.releaseConversation?.(conversationId)
|
||||
} finally {
|
||||
this.leave()
|
||||
}
|
||||
}
|
||||
|
||||
retire(): void {
|
||||
this.retired = true
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.retired = true
|
||||
await this.close()
|
||||
this.onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
/** Main-owned local processes; workspace facades never own their lifetime. */
|
||||
export class LocalRuntimeRegistry {
|
||||
private readonly current = new Map<string, LocalRuntimeEntry>()
|
||||
private readonly entries = new Set<LocalRuntimeEntry>()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly idleMs = IDLE_RUNTIME_MS) {}
|
||||
|
||||
acquire(
|
||||
key: string,
|
||||
configuration: unknown,
|
||||
workspace: string,
|
||||
create: () => LocalProcessRuntime
|
||||
): AgentRuntime {
|
||||
if (this.disposed) throw new Error('Runtime 正在关闭')
|
||||
let entry = this.current.get(key)
|
||||
if (entry && !isDeepStrictEqual(entry.configuration, configuration)) {
|
||||
this.current.delete(key)
|
||||
entry.retire()
|
||||
entry = undefined
|
||||
}
|
||||
if (!entry) {
|
||||
const created = new LocalRuntimeEntry(configuration, create, this.idleMs, () => {
|
||||
this.entries.delete(created)
|
||||
if (this.current.get(key) === created) this.current.delete(key)
|
||||
})
|
||||
this.current.set(key, created)
|
||||
this.entries.add(created)
|
||||
entry = created
|
||||
}
|
||||
return new WorkspaceRuntime(entry, workspace)
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
await Promise.all([...this.entries].map((entry) => entry.releaseConversation(conversationId)))
|
||||
}
|
||||
|
||||
reset(keep?: AgentRuntime): void {
|
||||
const retained = keep instanceof WorkspaceRuntime ? keep.owner : undefined
|
||||
for (const [key, entry] of this.current) {
|
||||
if (entry === retained) continue
|
||||
this.current.delete(key)
|
||||
entry.retire()
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.current.clear()
|
||||
await Promise.all([...this.entries].map((entry) => entry.dispose()))
|
||||
}
|
||||
}
|
||||
|
||||
class WorkspaceRuntime implements AgentRuntime {
|
||||
readonly sharedProcess = true
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly entry: LocalRuntimeEntry,
|
||||
private readonly workspace: string
|
||||
) {}
|
||||
|
||||
get owner(): LocalRuntimeEntry { return this.entry }
|
||||
get runtimeId() { return this.entry.description.runtimeId }
|
||||
get requiresToolApproval() { return this.entry.description.requiresToolApproval }
|
||||
get supportsToolExecution() { return this.entry.description.supportsToolExecution }
|
||||
get supportsScopedDataTools() { return this.entry.description.supportsScopedDataTools }
|
||||
get capability() { return this.entry.description.capability }
|
||||
|
||||
private check(): void {
|
||||
if (this.disposed) throw new Error('Runtime 工作区已释放')
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
this.check()
|
||||
return this.entry.getStatus()
|
||||
}
|
||||
|
||||
testConnection() {
|
||||
this.check()
|
||||
return this.entry.invoke((runtime) => runtime.testConnection?.() ?? runtime.getStatus())
|
||||
}
|
||||
|
||||
getNativeSnapshot() {
|
||||
this.check()
|
||||
return this.entry.invoke((runtime) => {
|
||||
if (!runtime.getNativeSnapshot) throw new Error('当前 Runtime 不支持原生能力清单')
|
||||
return runtime.getNativeSnapshot(this.workspace)
|
||||
})
|
||||
}
|
||||
|
||||
async *run(
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
authorize?: RuntimeAuthorizer
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
this.check()
|
||||
signal.throwIfAborted()
|
||||
const runtime = await this.entry.enter()
|
||||
try {
|
||||
yield* runtime.run({ ...request, executionWorkspace: this.workspace }, signal, authorize)
|
||||
} finally {
|
||||
this.entry.leave()
|
||||
}
|
||||
}
|
||||
|
||||
compactConversation(request: RuntimeConversationCompactInput, signal: AbortSignal) {
|
||||
this.check()
|
||||
return this.entry.invoke((runtime) => {
|
||||
if (!runtime.compactConversation) throw new Error('当前 Runtime 不支持手动压缩')
|
||||
return runtime.compactConversation(request, signal)
|
||||
})
|
||||
}
|
||||
|
||||
respondToQuestion(questionId: string, answers?: AgentQuestionAnswer[]) {
|
||||
this.check()
|
||||
return this.entry.respondToQuestion(questionId, answers)
|
||||
}
|
||||
|
||||
releaseConversation(conversationId: string) {
|
||||
return this.entry.releaseConversation(conversationId)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { createCanvas } from '@napi-rs/canvas'
|
||||
import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
|
||||
import { createAgentRuntime } from './create-runtime'
|
||||
import { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import { SelectedRuntimeManager } from './selected-runtime-manager'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
|
||||
const binaryPath = join(
|
||||
process.cwd(), '.runtime-resources', process.arch,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
)
|
||||
|
||||
it.skipIf(!existsSync(binaryPath)).each([2, 10])('uses one real OpenCode server across %i projects for tools, continuation and compaction', async (projectCount) => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-reuse-')))
|
||||
const directories = Array.from(
|
||||
{ length: projectCount }, (_, index) => join(root, `project-${index}`)
|
||||
)
|
||||
await Promise.all(directories.map(async (directory, index) => {
|
||||
await mkdir(directory)
|
||||
await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`)
|
||||
}))
|
||||
let modelCalls = 0
|
||||
const toolResults: unknown[] = []
|
||||
const capabilityResults: string[] = []
|
||||
const observedImages = new Set<string>()
|
||||
const requestImages = new Map<string, string>()
|
||||
const observedRequestImages = new Map<string, Set<string>>()
|
||||
const images = directories.slice(0, 2).map((_, index) =>
|
||||
createCanvas(index + 1, 1).toBuffer('image/png').toString('base64'))
|
||||
const knowledge = projectCount === 2 ? new KnowledgeService({
|
||||
databasePath: join(root, 'knowledge.sqlite'), managedRoot: join(root, 'knowledge')
|
||||
}) : undefined
|
||||
const gateway = knowledge ? new KnowledgeMcpGateway(knowledge) : undefined
|
||||
await knowledge?.initialize()
|
||||
await gateway?.start()
|
||||
const grants = gateway ? vi.spyOn(gateway, 'grantCustomMcp') : undefined
|
||||
const mcpCalls = gateway ? vi.spyOn(gateway as unknown as {
|
||||
callCustomMcpTool(token: string, binding: unknown, args: Record<string, unknown>, signal: AbortSignal): Promise<unknown>
|
||||
}, 'callCustomMcpTool') : undefined
|
||||
const model = createServer((request, response) => {
|
||||
void (async () => {
|
||||
let content = ''
|
||||
for await (const chunk of request) content += String(chunk)
|
||||
const body = JSON.parse(content) as {
|
||||
messages: Array<{ role: string; content: unknown }>
|
||||
tools?: Array<{ function: { name: string } }>
|
||||
}
|
||||
modelCalls++
|
||||
let userIndex = -1
|
||||
body.messages.forEach((message, index) => { if (message.role === 'user') userIndex = index })
|
||||
const results = body.messages.slice(userIndex + 1).filter((message) => message.role === 'tool')
|
||||
const result = results.at(-1)
|
||||
if (result) toolResults.push(result.content)
|
||||
const resultText = JSON.stringify(results.map(message => message.content))
|
||||
const marker = resultText.match(/PROJECT_\d+/u)?.[0]
|
||||
const read = body.tools?.some((tool) => tool.function.name === 'read')
|
||||
let tool: { name: string; arguments: Record<string, unknown> } | undefined =
|
||||
read && !result ? { name: 'read', arguments: { filePath: 'marker.txt' } } : undefined
|
||||
if (projectCount === 2 && read) {
|
||||
const requestId = JSON.stringify(body.messages[userIndex]?.content).match(/REQUEST_([a-f0-9-]{36})/u)?.[1]
|
||||
for (const image of images) {
|
||||
if (JSON.stringify(body.messages).includes(image)) {
|
||||
observedImages.add(image)
|
||||
if (requestId) {
|
||||
const seen = observedRequestImages.get(requestId) ?? new Set<string>()
|
||||
seen.add(image)
|
||||
observedRequestImages.set(requestId, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.length === 1) {
|
||||
tool = { name: 'skill', arguments: { name: 'web-3d-game' } }
|
||||
} else if (results.length === 2) {
|
||||
const name = body.tools?.find(item => item.function.name.endsWith('_create_game_blueprint'))?.function.name
|
||||
if (!name) throw new Error('Shared OpenCode MCP fixture is not exposed')
|
||||
tool = {
|
||||
name, arguments: {
|
||||
theme: 'neon-ruins',
|
||||
seed: JSON.stringify(body.messages[userIndex]?.content).match(/REQUEST_([a-f0-9-]{36})/u)?.[1],
|
||||
targetCount: 5
|
||||
}
|
||||
}
|
||||
} else if (results.length === 3) {
|
||||
capabilityResults.push(resultText)
|
||||
}
|
||||
}
|
||||
const callTool = tool !== undefined
|
||||
response.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
||||
const delta = callTool
|
||||
? {
|
||||
role: 'assistant',
|
||||
tool_calls: [{
|
||||
index: 0, id: `read-${modelCalls}`, type: 'function',
|
||||
function: { name: tool!.name, arguments: JSON.stringify(tool!.arguments) }
|
||||
}]
|
||||
}
|
||||
: { role: 'assistant', content: marker ?? 'CONTEXT_SUMMARY' }
|
||||
for (const [value, finish] of [[delta, null], [{}, callTool ? 'tool_calls' : 'stop']]) {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: `reuse-${modelCalls}`, object: 'chat.completion.chunk', created: 1,
|
||||
model: 'reuse', choices: [{ index: 0, delta: value, finish_reason: finish }]
|
||||
})}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => response.destroy())
|
||||
})
|
||||
await new Promise<void>((resolve) => model.listen(0, '127.0.0.1', resolve))
|
||||
const port = (model.address() as { port: number }).port
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000008',
|
||||
name: 'Reuse fixture', modelName: 'reuse', baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
protocol: 'openai-chat-completions' as const, authentication: 'none' as const,
|
||||
supportsImageInput: projectCount === 2
|
||||
}
|
||||
const settings = {
|
||||
...defaultRuntimeSettings, provider: 'opencode', opencodeBinaryPath: binaryPath,
|
||||
modelProfiles: [profile], opencodeModelProfile: profile
|
||||
} as ResolvedRuntimeSettings
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const create = vi.fn(async (_selection, executionSpace) => createAgentRuntime(root, settings, {
|
||||
executionSpace, localRuntimeRegistry: registry, knowledgeGateway: gateway,
|
||||
...(projectCount === 2 ? {
|
||||
skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }],
|
||||
mcpServers: [{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '',
|
||||
enabled: true, allowDynamicTools: false, assignments: ['opencode' as const],
|
||||
secretConfigured: false, transport: 'stdio' as const,
|
||||
command: process.versions.electron ? 'node' : process.execPath,
|
||||
args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')]
|
||||
}]
|
||||
} : {})
|
||||
}))
|
||||
const manager = new SelectedRuntimeManager(create, 1, 1, undefined, undefined, (id) => registry.releaseConversation(id))
|
||||
const connections = vi.spyOn(OpenCodeRuntime.prototype, 'testConnection')
|
||||
const launches = vi.spyOn(
|
||||
OpenCodeRuntime.prototype as unknown as { launchEmbedded(): Promise<unknown> },
|
||||
'launchEmbedded'
|
||||
)
|
||||
const nativeSessions = new Set<string>()
|
||||
try {
|
||||
const selection = { provider: 'opencode' as const }
|
||||
const facades = await Promise.all(directories.map((directory) =>
|
||||
manager.getRuntime(selection, resolver.resolveLocal(directory))
|
||||
))
|
||||
for (let wave = 0; wave < 2; wave++) {
|
||||
await Promise.all(facades.flatMap((runtime, project) => [0, 1].map(async (conversation) => {
|
||||
let output = ''
|
||||
const requestId = crypto.randomUUID()
|
||||
if (projectCount === 2 && wave === 0) requestImages.set(requestId, images[project]!)
|
||||
for await (const event of runtime.run({
|
||||
requestId, conversationId: `project-${project}-${conversation}`,
|
||||
prompt: `Read marker.txt for wave ${wave}. REQUEST_${requestId}`, workMode: 'execute',
|
||||
...(projectCount === 2 && wave === 0 ? { images: [{
|
||||
name: `project-${project}.png`, mediaType: 'image/png', data: images[project]!
|
||||
}] } : {})
|
||||
}, AbortSignal.timeout(40_000))) {
|
||||
if (event.type === 'text') output += event.delta
|
||||
if (event.type === 'done' && event.sessionId) nativeSessions.add(event.sessionId)
|
||||
}
|
||||
expect(output, JSON.stringify(toolResults)).toBe(`PROJECT_${project}`)
|
||||
})))
|
||||
}
|
||||
expect(nativeSessions.size).toBe(projectCount * 2)
|
||||
if (projectCount === 2) {
|
||||
expect(observedImages.size).toBe(2)
|
||||
for (const [requestId, image] of requestImages) {
|
||||
expect(observedRequestImages.get(requestId)).toEqual(new Set([image]))
|
||||
}
|
||||
expect(capabilityResults).toHaveLength(8)
|
||||
for (const result of capabilityResults) {
|
||||
expect(result).toContain('window.__GOODBUDDY_GAME__')
|
||||
expect(result).toContain('Prism Relay')
|
||||
}
|
||||
expect(mcpCalls).toHaveBeenCalledTimes(8)
|
||||
for (const [token, , args] of mcpCalls!.mock.calls) {
|
||||
const grantIndex = grants!.mock.results.findIndex(result => result.value === token)
|
||||
expect(grantIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(args.seed).toBe(grants!.mock.calls[grantIndex]![0])
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < facades.length; index++) {
|
||||
await expect(facades[index]!.compactConversation({
|
||||
requestId: crypto.randomUUID(), conversationId: `project-${index}-0`,
|
||||
runtimeSelection: selection, history: [], historyMessageIds: []
|
||||
}, AbortSignal.timeout(30_000))).resolves.toMatchObject({ result: { compacted: true } })
|
||||
}
|
||||
await Promise.all(facades.map((runtime) => runtime.testConnection()))
|
||||
// Every facade invokes the same process-owning Runtime, even beyond capacity 1.
|
||||
expect(new Set(connections.mock.instances).size).toBe(1)
|
||||
expect(launches).toHaveBeenCalledOnce()
|
||||
for (const [index, directory] of directories.entries()) {
|
||||
expect(await readFile(join(directory, 'marker.txt'), 'utf8')).toBe(`PROJECT_${index}`)
|
||||
}
|
||||
await manager.releaseConversation('project-0-0')
|
||||
} finally {
|
||||
connections.mockRestore()
|
||||
launches.mockRestore()
|
||||
mcpCalls?.mockRestore()
|
||||
grants?.mockRestore()
|
||||
await manager.dispose()
|
||||
await registry.dispose()
|
||||
await gateway?.dispose()
|
||||
await knowledge?.dispose()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>((resolve) => model.close(() => resolve()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
@@ -1692,6 +1692,31 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
it("keeps a shared cold start alive when only its first waiter cancels", async () => {
|
||||
const child = fakeChild();
|
||||
let healthy = false;
|
||||
const { deps, spawnMock } = dependencies(child, {
|
||||
startupTimeoutMs: 2_000,
|
||||
checkServerHealth: vi.fn(async () => healthy),
|
||||
});
|
||||
const runtime = new OpenCodeRuntime(options(), deps);
|
||||
const controller = new AbortController();
|
||||
const pending = runtime.run({
|
||||
requestId: "cancel-initializer", conversationId: "first",
|
||||
prompt: "test", executionWorkspace: resolve("one"),
|
||||
}, controller.signal).next();
|
||||
const rejected = expect(pending).rejects.toThrow("启动已取消");
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
const peer = runtime.testConnection();
|
||||
controller.abort();
|
||||
await rejected;
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
healthy = true;
|
||||
await expect(peer).resolves.toMatchObject({ available: true });
|
||||
expect(spawnMock).toHaveBeenCalledOnce();
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it("bounds session setup without imposing a full-run deadline", async () => {
|
||||
const harness = runClient([]);
|
||||
(
|
||||
|
||||
@@ -729,7 +729,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
private clientInitialization?: Promise<OpencodeClient>;
|
||||
private server?: OpenCodeServer;
|
||||
private startingChild?: SpawnedProcess;
|
||||
private readonly sessions = new Map<string, string>();
|
||||
private readonly sessions = new Map<string, { id: string; directory: string }>();
|
||||
private initializationController?: AbortController;
|
||||
private initializationWaiters = 0;
|
||||
private readonly sessionInitializations = new Map<string, Promise<string>>();
|
||||
private readonly pendingQuestions = new Map<
|
||||
string,
|
||||
@@ -761,6 +763,10 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
get hasRetainedSessions(): boolean {
|
||||
return this.sessions.size > 0 || this.sessionInitializations.size > 0;
|
||||
}
|
||||
|
||||
private usesEmbeddedPermissionMediation(): boolean {
|
||||
return this.options.embedded && !this.options.baseUrl;
|
||||
}
|
||||
@@ -1276,19 +1282,38 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (this.client && !this.server) {
|
||||
return this.client;
|
||||
}
|
||||
const existingInitialization = this.clientInitialization;
|
||||
const cached = Boolean(this.client && this.server);
|
||||
// Cached health checks are shared, but each caller owns only its wait.
|
||||
const initialization = this.clientInitialization ??= this.initializeClient(
|
||||
cached ? undefined : signal,
|
||||
).finally(() => {
|
||||
if (this.clientInitialization === initialization) {
|
||||
this.clientInitialization = undefined;
|
||||
this.initializationWaiters += 1;
|
||||
if (!this.clientInitialization) {
|
||||
const controller = this.initializationController = new AbortController();
|
||||
const initialization = this.initializeClient(
|
||||
cached ? undefined : controller.signal,
|
||||
).finally(() => {
|
||||
if (this.clientInitialization === initialization) {
|
||||
this.clientInitialization = undefined;
|
||||
this.initializationController = undefined;
|
||||
}
|
||||
});
|
||||
this.clientInitialization = initialization;
|
||||
}
|
||||
try {
|
||||
return signal
|
||||
? await awaitWithAbort(this.clientInitialization, signal)
|
||||
: await this.clientInitialization;
|
||||
} catch (error) {
|
||||
if (signal?.aborted && !cached && !this.client) {
|
||||
throw new Error("OpenCode Server 启动已取消", { cause: error });
|
||||
}
|
||||
});
|
||||
return signal && (cached || existingInitialization)
|
||||
? await awaitWithAbort(initialization, signal)
|
||||
: await initialization;
|
||||
throw error;
|
||||
} finally {
|
||||
this.initializationWaiters -= 1;
|
||||
if (this.initializationWaiters === 0 && !this.client) {
|
||||
this.initializationController?.abort(
|
||||
new Error("OpenCode 初始化已无等待请求"),
|
||||
);
|
||||
await this.clientInitialization?.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async controlRequest<T>(
|
||||
@@ -1460,6 +1485,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
private async discoverAgents(
|
||||
client: OpencodeClient,
|
||||
signal?: AbortSignal,
|
||||
directory = this.options.defaultWorkspace,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
@@ -1475,7 +1501,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
(controlSignal) =>
|
||||
client.app.agents(
|
||||
{
|
||||
directory: this.options.defaultWorkspace,
|
||||
directory,
|
||||
},
|
||||
{ signal: controlSignal },
|
||||
),
|
||||
@@ -1521,7 +1547,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (!this.supportsNativeCustomization()) {
|
||||
throw new Error("外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent");
|
||||
}
|
||||
const agents = await this.discoverAgents(client, signal);
|
||||
const agents = await this.discoverAgents(
|
||||
client, signal, request.executionWorkspace ?? this.options.defaultWorkspace,
|
||||
);
|
||||
if (
|
||||
!agents.some(
|
||||
(agent) =>
|
||||
@@ -1537,7 +1565,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
return selected;
|
||||
}
|
||||
|
||||
async getNativeSnapshot(): Promise<RuntimeNativeSnapshot> {
|
||||
async getNativeSnapshot(
|
||||
directory = this.options.defaultWorkspace,
|
||||
): Promise<RuntimeNativeSnapshot> {
|
||||
const controlled = this.supportsNativeCustomization();
|
||||
const empty: RuntimeNativeSnapshot = {
|
||||
provider: "opencode",
|
||||
@@ -1572,7 +1602,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const response = await this.controlRequest("连接检查", (signal) =>
|
||||
client.session.list(
|
||||
{
|
||||
directory: this.options.defaultWorkspace,
|
||||
directory,
|
||||
limit: 1,
|
||||
},
|
||||
{ signal },
|
||||
@@ -1602,7 +1632,6 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
const directory = this.options.defaultWorkspace;
|
||||
const assignedSkillIds = new Set(
|
||||
(this.options.skillPackages ?? []).map((skill) => skill.id),
|
||||
);
|
||||
@@ -1626,7 +1655,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
{ signal },
|
||||
),
|
||||
),
|
||||
this.discoverAgents(client),
|
||||
this.discoverAgents(client, undefined, directory),
|
||||
this.controlRequest("读取工具清单", (signal) =>
|
||||
client.tool.ids({ directory }, { signal }),
|
||||
),
|
||||
@@ -1914,8 +1943,11 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
permission?: PermissionRuleset,
|
||||
): Promise<{ id: string; created: boolean }> {
|
||||
const current = this.sessions.get(request.conversationId);
|
||||
if (current?.directory === directory) {
|
||||
return { id: current.id, created: false };
|
||||
}
|
||||
if (current) {
|
||||
return { id: current, created: false };
|
||||
await this.releaseConversation(request.conversationId);
|
||||
}
|
||||
const pending = this.sessionInitializations.get(request.conversationId);
|
||||
if (pending) {
|
||||
@@ -1959,7 +1991,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const stillCurrent =
|
||||
this.sessionInitializations.get(request.conversationId) === creation;
|
||||
if (signal.aborted || !stillCurrent) {
|
||||
if (this.sessions.get(request.conversationId) !== sessionId) {
|
||||
if (this.sessions.get(request.conversationId)?.id !== sessionId) {
|
||||
void client.session
|
||||
.delete(
|
||||
{
|
||||
@@ -1975,7 +2007,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
throw new Error("OpenCode 会话初始化已失效");
|
||||
}
|
||||
this.sessions.set(request.conversationId, sessionId);
|
||||
this.sessions.set(request.conversationId, { id: sessionId, directory });
|
||||
return sessionId;
|
||||
});
|
||||
this.sessionInitializations.set(request.conversationId, creation);
|
||||
@@ -2072,7 +2104,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
signal: AbortSignal,
|
||||
client: OpencodeClient,
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
const directory = this.options.defaultWorkspace;
|
||||
const directory = resolve(request.executionWorkspace ?? this.options.defaultWorkspace);
|
||||
const runtimeControl =
|
||||
request.runtimeControl?.provider === "opencode"
|
||||
? request.runtimeControl
|
||||
@@ -3065,7 +3097,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
executionSignal = AbortSignal.any([executionSignal, server.signal]);
|
||||
}
|
||||
}
|
||||
const sessionId = this.sessions.get(request.conversationId);
|
||||
const session = this.sessions.get(request.conversationId);
|
||||
const sessionId = session?.id;
|
||||
if (!sessionId || !client) {
|
||||
return {
|
||||
result: {
|
||||
@@ -3076,6 +3109,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
},
|
||||
};
|
||||
}
|
||||
const directory = session!.directory;
|
||||
const context = await this.controlRequest(
|
||||
"读取原生上下文",
|
||||
(controlSignal) =>
|
||||
@@ -3122,7 +3156,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
"订阅 Compact 事件流",
|
||||
(controlSignal) =>
|
||||
client.event.subscribe(
|
||||
{ directory: this.options.defaultWorkspace },
|
||||
{ directory },
|
||||
eventSubscriptionOptions(controlSignal),
|
||||
),
|
||||
subscriptionSignal,
|
||||
@@ -3162,7 +3196,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const compact = await client.session.summarize(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory: this.options.defaultWorkspace,
|
||||
directory,
|
||||
providerID: configuredModel.providerID,
|
||||
modelID: configuredModel.modelID,
|
||||
auto: false,
|
||||
@@ -3220,6 +3254,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.initializationController?.abort(new Error("OpenCode Runtime 已关闭"));
|
||||
this.pendingQuestions.clear();
|
||||
const startingChild = this.startingChild;
|
||||
this.startingChild = undefined;
|
||||
@@ -3238,16 +3273,16 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const sessionId = this.sessions.get(conversationId);
|
||||
const session = this.sessions.get(conversationId);
|
||||
this.sessions.delete(conversationId);
|
||||
if (!sessionId || !this.client) {
|
||||
if (!session || !this.client) {
|
||||
return;
|
||||
}
|
||||
await this.controlRequest("释放会话", (signal) =>
|
||||
this.client!.session.delete(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory: this.options.defaultWorkspace,
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
|
||||
@@ -11,6 +11,39 @@ import {
|
||||
RuntimeReplacementCapacityError
|
||||
} from './runtime-controller'
|
||||
|
||||
it('routes a shared request question to its original Runtime across replacement', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
const previous: AgentRuntime = {
|
||||
sharedProcess: true, requiresToolApproval: false, supportsToolExecution: true,
|
||||
getStatus: vi.fn(), dispose: vi.fn(async () => undefined),
|
||||
respondToQuestion: vi.fn(async () => undefined),
|
||||
async *run(request) {
|
||||
yield { type: 'question', requestId: request.requestId, questionId: 'question-old', questions: [] }
|
||||
await gate
|
||||
yield { type: 'done', requestId: request.requestId }
|
||||
}
|
||||
}
|
||||
const next: AgentRuntime = { ...previous, respondToQuestion: vi.fn(async () => undefined) }
|
||||
const controller = new AgentRuntimeController(previous, 1)
|
||||
const stream = controller.run({
|
||||
requestId: 'old-request', conversationId: 'old-conversation', prompt: 'test', workMode: 'execute'
|
||||
}, new AbortController().signal)
|
||||
try {
|
||||
expect((await stream.next()).value?.type).toBe('question')
|
||||
await controller.replace(next)
|
||||
await controller.respondToQuestion('question-old', [])
|
||||
expect(previous.respondToQuestion).toHaveBeenCalledWith('question-old', [])
|
||||
expect(next.respondToQuestion).not.toHaveBeenCalled()
|
||||
release()
|
||||
expect((await stream.next()).value?.type).toBe('done')
|
||||
} finally {
|
||||
release()
|
||||
await stream.return()
|
||||
await controller.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
class TestRuntime implements AgentRuntime {
|
||||
readonly dispose = vi.fn(async () => {})
|
||||
readonly started: Promise<void>
|
||||
@@ -176,6 +209,26 @@ describe('AgentRuntimeController', () => {
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('lets a shared local request finish while replacing its workspace facade', async () => {
|
||||
const previous = Object.assign(new TestRuntime(true), { sharedProcess: true })
|
||||
const controller = new AgentRuntimeController(previous, 1)
|
||||
const stream = controller.run({
|
||||
requestId: '00000000-0000-4000-8000-000000000071',
|
||||
conversationId: 'shared-replacement', prompt: 'continue', workMode: 'ask'
|
||||
}, new AbortController().signal)
|
||||
const pending = stream.next()
|
||||
await previous.started
|
||||
await controller.replace(new TestRuntime())
|
||||
expect(previous.dispose).not.toHaveBeenCalled()
|
||||
previous.finish()
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
done: false, value: { type: 'text' }
|
||||
})
|
||||
await stream.next()
|
||||
expect(previous.dispose).toHaveBeenCalledOnce()
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('drains remote sessions without interrupting their active prompt', async () => {
|
||||
const previous = new DrainableTestRuntime()
|
||||
const next = new TestRuntime()
|
||||
|
||||
@@ -80,6 +80,7 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
private retirement?: Promise<void>
|
||||
private disposal?: Promise<void>
|
||||
private readonly ownedConversationIds = new Set<string>()
|
||||
private readonly questionOwners = new Map<string, { slot: RuntimeSlot; requestId: string }>()
|
||||
|
||||
constructor(
|
||||
runtime: AgentRuntime,
|
||||
@@ -147,11 +148,15 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
return this.ownedConversationIds.size
|
||||
}
|
||||
|
||||
get sharedProcess(): boolean {
|
||||
return this.current.runtime.sharedProcess === true
|
||||
}
|
||||
|
||||
get canRetire(): boolean {
|
||||
return (
|
||||
!this.closing &&
|
||||
this.activeRequestCount === 0 &&
|
||||
this.ownedConversationIds.size === 0
|
||||
(this.sharedProcess || this.ownedConversationIds.size === 0)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,7 +274,7 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
slot.activeRequests += 1
|
||||
try {
|
||||
const status = await operation(slot.runtime)
|
||||
if (slot !== this.current) {
|
||||
if (slot !== this.current && !(slot.runtime.sharedProcess && stage === 'compact')) {
|
||||
throw new Error('Runtime 已切换,请重试')
|
||||
}
|
||||
return status
|
||||
@@ -347,13 +352,16 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
signal,
|
||||
effectiveAuthorize
|
||||
)) {
|
||||
if (slot !== this.current && !slot.drainable) {
|
||||
if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) {
|
||||
throw new Error('Runtime 已切换,当前请求已中断')
|
||||
}
|
||||
toolDenied = false
|
||||
if (event.type === 'question') {
|
||||
this.questionOwners.set(event.questionId, { slot, requestId: request.requestId })
|
||||
}
|
||||
yield event
|
||||
}
|
||||
if (slot !== this.current && !slot.drainable) {
|
||||
if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) {
|
||||
throw new Error('Runtime 已切换,当前请求已中断')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -368,6 +376,9 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
for (const [questionId, owner] of this.questionOwners) {
|
||||
if (owner.requestId === request.requestId) this.questionOwners.delete(questionId)
|
||||
}
|
||||
slot.activeRequests -= 1
|
||||
if (
|
||||
slot.retiring &&
|
||||
@@ -419,14 +430,16 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[]
|
||||
): Promise<void> {
|
||||
if (this.closing) {
|
||||
const owner = this.questionOwners.get(questionId)
|
||||
if (this.closing && !owner) {
|
||||
throw new Error('Agent Runtime 正在关闭')
|
||||
}
|
||||
const runtime = this.current.runtime
|
||||
const runtime = (owner?.slot ?? this.current).runtime
|
||||
if (!runtime.respondToQuestion) {
|
||||
throw new Error('当前 Runtime 不支持回答交互式问题')
|
||||
}
|
||||
await runtime.respondToQuestion(questionId, answers)
|
||||
this.questionOwners.delete(questionId)
|
||||
}
|
||||
|
||||
private retireSlot(slot: RuntimeSlot): Promise<void> {
|
||||
|
||||
@@ -86,6 +86,9 @@ export type RuntimeConversationCompactOutcome = {
|
||||
}
|
||||
|
||||
export interface AgentRuntime {
|
||||
/** A workspace facade whose sessions are owned by a shared local Runtime. */
|
||||
readonly sharedProcess?: boolean
|
||||
readonly hasRetainedSessions?: boolean
|
||||
readonly runtimeId?: AgentRuntimeStatus['id']
|
||||
readonly requiresToolApproval: boolean
|
||||
readonly supportsToolExecution: boolean
|
||||
@@ -94,7 +97,7 @@ export interface AgentRuntime {
|
||||
readonly capability?: 'chat' | 'image-generation'
|
||||
getStatus(): Promise<AgentRuntimeStatus>
|
||||
testConnection?(): Promise<AgentRuntimeStatus>
|
||||
getNativeSnapshot?(): Promise<RuntimeNativeSnapshot>
|
||||
getNativeSnapshot?(workspace?: string): Promise<RuntimeNativeSnapshot>
|
||||
compactConversation?(
|
||||
request: RuntimeConversationCompactInput,
|
||||
signal: AbortSignal
|
||||
@@ -132,6 +135,8 @@ export type RemoteRecoveredSubagent =
|
||||
ConversationSubagentActivity
|
||||
|
||||
export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
|
||||
/** Resolved Main-side workspace, never taken from the current UI selection. */
|
||||
executionWorkspace?: string
|
||||
workMode?: WorkMode
|
||||
images?: AgentImage[]
|
||||
imageContextNotice?: ConversationMessage['imageContextNotice']
|
||||
|
||||
@@ -510,6 +510,44 @@ describe('SelectedRuntimeManager', () => {
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('answers owned questions after settings reset while rejecting new work', async () => {
|
||||
const active = runtime()
|
||||
active.value.respondToQuestion = vi.fn(async () => undefined)
|
||||
active.value.run = async function* (request) {
|
||||
yield {
|
||||
requestId: request.requestId, type: 'question', questionId: 'retained-question',
|
||||
questions: [{ header: 'Choice', question: 'Continue?', options: [], multiple: false, custom: true }]
|
||||
}
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
const manager = new SelectedRuntimeManager(async () => active.value)
|
||||
const controller = await manager.getRuntime({ provider: 'opencode' }, executionSpace('project-one'))
|
||||
const stream = controller.run({
|
||||
requestId: 'retained-request', conversationId: 'retained-conversation',
|
||||
prompt: 'Ask a question', workMode: 'execute'
|
||||
}, new AbortController().signal)
|
||||
try {
|
||||
expect((await stream.next()).value?.type).toBe('question')
|
||||
await manager.reset()
|
||||
expect(active.dispose).not.toHaveBeenCalled()
|
||||
await expect(controller.respondToQuestion('unknown-question', [['Yes']]))
|
||||
.rejects.toThrow('正在关闭')
|
||||
await expect(controller.respondToQuestion('retained-question', [['Yes']]))
|
||||
.resolves.toBeUndefined()
|
||||
expect(active.value.respondToQuestion).toHaveBeenCalledWith('retained-question', [['Yes']])
|
||||
await expect(controller.run({
|
||||
requestId: 'new-request', conversationId: 'new-conversation',
|
||||
prompt: 'New work', workMode: 'execute'
|
||||
}, new AbortController().signal).next()).rejects.toThrow('正在关闭')
|
||||
expect((await stream.next()).value?.type).toBe('done')
|
||||
await stream.next()
|
||||
await vi.waitFor(() => expect(active.dispose).toHaveBeenCalledOnce())
|
||||
} finally {
|
||||
await stream.return()
|
||||
await manager.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes application exit through cached runtime controllers without ordinary disposal', async () => {
|
||||
const active = runtime()
|
||||
const detachForApplicationExit = vi.fn(() => undefined)
|
||||
@@ -595,7 +633,8 @@ describe('SelectedRuntimeManager', () => {
|
||||
await expect(
|
||||
manager.getRuntime(selection('43'))
|
||||
).rejects.toThrow(/退役容量/iu)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
expect(create).toHaveBeenCalledTimes(3)
|
||||
expect(created[2]!.dispose).toHaveBeenCalledOnce()
|
||||
await manager.dispose()
|
||||
expect(created[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(created[1]!.dispose).toHaveBeenCalledOnce()
|
||||
@@ -607,7 +646,10 @@ describe('SelectedRuntimeManager', () => {
|
||||
finishCreate = resolve
|
||||
})
|
||||
const created = runtime()
|
||||
const create = vi.fn(async () => await pendingCreate)
|
||||
const rejected = runtime()
|
||||
const create = vi.fn()
|
||||
.mockReturnValueOnce(pendingCreate)
|
||||
.mockResolvedValueOnce(rejected.value)
|
||||
const manager = new SelectedRuntimeManager(create, 1, 1)
|
||||
const selection = (suffix: string) => ({
|
||||
provider: 'model' as const,
|
||||
@@ -622,7 +664,8 @@ describe('SelectedRuntimeManager', () => {
|
||||
await expect(
|
||||
manager.getRuntime(selection('52'))
|
||||
).rejects.toThrow(/退役容量/iu)
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
expect(rejected.dispose).toHaveBeenCalledOnce()
|
||||
|
||||
finishCreate(created.value)
|
||||
await expect(firstRuntime).resolves.toEqual(
|
||||
|
||||
@@ -76,7 +76,8 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
DEFAULT_MAXIMUM_RETAINED_RUNTIMES,
|
||||
private readonly observeFailure?:
|
||||
DesktopDiagnosticFailureObserver,
|
||||
private readonly createStatusRuntime = createRuntime
|
||||
private readonly createStatusRuntime = createRuntime,
|
||||
private readonly releaseSharedConversation?: (conversationId: string) => Promise<void>
|
||||
) {
|
||||
if (
|
||||
!Number.isSafeInteger(maximumCachedRuntimes) ||
|
||||
@@ -111,21 +112,6 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
return existing.operation
|
||||
}
|
||||
this.evictIdleEntries(this.maximumCachedRuntimes - 1)
|
||||
if (this.entries.size >= this.maximumCachedRuntimes) {
|
||||
throw new Error(
|
||||
'Agent Runtime 缓存已被活动会话占满,请先关闭不再使用的会话'
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.entries.size +
|
||||
this.retiringControllers.size +
|
||||
this.pendingRetirementControllers >=
|
||||
this.maximumRetainedRuntimes
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话'
|
||||
)
|
||||
}
|
||||
const operation = this.createRuntime(selection, executionSpace).then(
|
||||
async (runtime) => {
|
||||
if (
|
||||
@@ -135,6 +121,21 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
await runtime.dispose()
|
||||
throw new Error('Runtime 设置已更改,请重新选择')
|
||||
}
|
||||
if (!runtime.sharedProcess) {
|
||||
const currentCount = [...this.entries.values()].filter(
|
||||
(candidate) => candidate !== entry && !candidate.controller?.sharedProcess
|
||||
).length
|
||||
const retiredCount = [...this.retiringControllers].filter(
|
||||
(candidate) => !candidate.sharedProcess
|
||||
).length + this.pendingRetirementControllers
|
||||
if (
|
||||
currentCount >= this.maximumCachedRuntimes ||
|
||||
currentCount + retiredCount >= this.maximumRetainedRuntimes
|
||||
) {
|
||||
await runtime.dispose()
|
||||
throw new Error('Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话')
|
||||
}
|
||||
}
|
||||
const controller = new AgentRuntimeController(
|
||||
runtime,
|
||||
undefined,
|
||||
@@ -234,6 +235,7 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
await this.releaseSharedConversation?.(conversationId)
|
||||
const controllers = await Promise.allSettled([
|
||||
...[...this.entries.values()].map(({ operation }) => operation)
|
||||
])
|
||||
|
||||
+259
-20
@@ -12,10 +12,13 @@ import { join } from 'node:path'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import type {
|
||||
AssistantProject,
|
||||
ConversationSnapshot,
|
||||
ConversationQueueItem
|
||||
} from '../shared/assistant-contracts'
|
||||
import {
|
||||
browserTabIdSchema,
|
||||
defaultRuntimeSettings,
|
||||
runtimeSettingsInputSchema,
|
||||
type AgentEvent,
|
||||
type BrowserLiveState,
|
||||
type BrowserTabId
|
||||
@@ -27,6 +30,7 @@ import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { KnowledgeService } from './knowledge/knowledge-service'
|
||||
import { BrowserNavigationStoppedError } from './browser/browser-service'
|
||||
import { RemotePromptCancelledError } from './agent/acp-remote-runtime'
|
||||
import {
|
||||
registerIpcHandlers,
|
||||
sendRemoteEnvironmentUpdateProgress,
|
||||
@@ -4863,7 +4867,7 @@ describe('registerIpcHandlers Runtime customization', () => {
|
||||
settingsStore.updateRuntimeCustomization
|
||||
).toHaveBeenCalledWith(customization)
|
||||
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
||||
expect(approvalBroker.clear).toHaveBeenCalledOnce()
|
||||
expect(approvalBroker.clear).not.toHaveBeenCalled()
|
||||
const activationError = new Error('customization activation failed')
|
||||
onRuntimeSettingsChanged
|
||||
.mockRejectedValueOnce(activationError)
|
||||
@@ -5062,7 +5066,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
appendRemoteTaskEventOnce: vi.fn<
|
||||
(input: RemoteTaskEventMockInput) => boolean
|
||||
>(() => true),
|
||||
appendRemoteConversationTaskEventOnce: vi.fn(() => true),
|
||||
appendRemoteConversationTaskEventOnce: vi.fn<AssistantDatabase['appendRemoteConversationTaskEventOnce']>(() => true),
|
||||
listConversations: vi.fn<() => ConversationSnapshot[]>(() => []),
|
||||
listRecoverableRemoteTasks: vi.fn<
|
||||
() => Array<{
|
||||
taskId: string
|
||||
@@ -5078,7 +5083,9 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
getHighestCommittedRemoteTaskEventSequenceForTask: vi.fn(
|
||||
() => '0'
|
||||
),
|
||||
failRecoverableRemoteTask: vi.fn(),
|
||||
endRecoverableRemoteTask: vi.fn(),
|
||||
recordRemoteTaskQuestionAnswer: vi.fn<AssistantDatabase['recordRemoteTaskQuestionAnswer']>(),
|
||||
getTask: vi.fn<AssistantDatabase['getTask']>(),
|
||||
updateTaskStatus: vi.fn(),
|
||||
createTextArtifact: vi.fn(),
|
||||
createImageArtifact: vi.fn(() => ({
|
||||
@@ -5235,9 +5242,16 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
})),
|
||||
updateRuntimeCustomization: vi.fn(
|
||||
async (settings: unknown) => settings
|
||||
)
|
||||
),
|
||||
captureRollback: vi.fn(async () => ({
|
||||
publicSettings: defaultRuntimeSettings,
|
||||
restore: async () => defaultRuntimeSettings
|
||||
})),
|
||||
update: vi.fn(async (settings: unknown) => settings)
|
||||
} as never,
|
||||
(capabilityServiceOverride ?? {}) as never,
|
||||
(capabilityServiceOverride ?? {
|
||||
setWebSearchEnabled: vi.fn(async () => ({}))
|
||||
}) as never,
|
||||
contextManager as never,
|
||||
(knowledgeServiceOverride ?? {
|
||||
database: { listKnowledgeBases: vi.fn(() => []) }
|
||||
@@ -5725,7 +5739,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('keeps a replacement lease owned after the bulk-aborted lease releases', async () => {
|
||||
it('keeps the active lease through settings reload and releases it only after cancellation settles', async () => {
|
||||
const controls = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -5803,7 +5817,25 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
opencode: {},
|
||||
continue: { presets: [] }
|
||||
})
|
||||
expect(controls.get(oldConversationId)?.signal.aborted).toBe(false)
|
||||
|
||||
await expect(harness.handler?.(event, {
|
||||
requestId,
|
||||
conversationId: newConversationId,
|
||||
prompt: '替换请求',
|
||||
workMode: 'ask',
|
||||
knowledgeLibraryIds: []
|
||||
})).rejects.toThrow('请求正在执行')
|
||||
|
||||
harness.cancelHandler?.(event, requestId)
|
||||
expect(controls.get(oldConversationId)?.signal.aborted).toBe(true)
|
||||
controls.get(oldConversationId)?.finish()
|
||||
await vi.waitFor(() =>
|
||||
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
||||
|
||||
await harness.handler?.(event, {
|
||||
requestId,
|
||||
@@ -5815,15 +5847,6 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(controls.has(newConversationId)).toBe(true)
|
||||
)
|
||||
|
||||
controls.get(oldConversationId)?.finish()
|
||||
await vi.waitFor(() =>
|
||||
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
||||
|
||||
harness.cancelHandler?.(event, requestId)
|
||||
expect(controls.get(newConversationId)?.signal.aborted).toBe(true)
|
||||
await expect(
|
||||
@@ -5868,6 +5891,64 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('cancels only an expert-team parent on settings replacement without synthesizing on the replacement model', async () => {
|
||||
let rejectExpert!: (error: Error) => void
|
||||
let parentSignal: AbortSignal | undefined
|
||||
const subagents = {
|
||||
run: vi.fn(async (input: { expert: { name: string }; signal: AbortSignal }) => {
|
||||
parentSignal = input.signal
|
||||
if (input.expert.name === 'First') return { childTaskId: 'first', output: 'Finished analysis' }
|
||||
return new Promise((_, reject) => { rejectExpert = reject })
|
||||
}),
|
||||
synthesize: vi.fn(async () => 'Must not use replacement model'),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
let finishPeer!: () => void
|
||||
let peerSignal: AbortSignal | undefined
|
||||
const runtime = {
|
||||
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
|
||||
async *run(request: { requestId: string }, signal: AbortSignal) {
|
||||
peerSignal = signal
|
||||
await new Promise<void>(resolve => { finishPeer = resolve })
|
||||
yield { requestId: request.requestId, type: 'done' as const }
|
||||
}
|
||||
}
|
||||
const harness = createHarness(runtime, undefined, 'always', subagents)
|
||||
harness.assistantDatabase.listExperts.mockReturnValue([
|
||||
{ id: '00000000-0000-4000-8000-000000000781', name: 'First', enabled: true },
|
||||
{ id: '00000000-0000-4000-8000-000000000782', name: 'Second', enabled: true }
|
||||
])
|
||||
harness.onRuntimeSettingsChanged.mockImplementation(async () => {
|
||||
rejectExpert(new Error('默认模型设置已更改'))
|
||||
})
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const teamId = '00000000-0000-4000-8000-000000000783'
|
||||
try {
|
||||
await harness.handler?.(event, {
|
||||
requestId: teamId, conversationId: 'expert-team',
|
||||
prompt: 'Analyze with the team', teamMode: true, workMode: 'ask', knowledgeLibraryIds: []
|
||||
})
|
||||
await vi.waitFor(() => expect(subagents.run).toHaveBeenCalledTimes(2))
|
||||
await harness.handler?.(event, {
|
||||
requestId: '00000000-0000-4000-8000-000000000784', conversationId: 'ordinary-peer',
|
||||
prompt: 'Keep working', workMode: 'ask', knowledgeLibraryIds: []
|
||||
})
|
||||
await vi.waitFor(() => expect(peerSignal).toBeDefined())
|
||||
await electronMocks.handlers.get(ipcChannels.runtimeCustomizationUpdate)!(event, {
|
||||
opencode: {}, continue: { presets: [] }
|
||||
})
|
||||
expect(parentSignal?.aborted).toBe(true)
|
||||
expect(peerSignal?.aborted).toBe(false)
|
||||
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus)
|
||||
.toHaveBeenCalledWith(teamId, 'cancelled', '请求已取消'))
|
||||
expect(subagents.synthesize).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rejectExpert?.(new Error('Test cleanup'))
|
||||
finishPeer?.()
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('detaches an accepted managed SSH run without aborting it or waiting for completion', async () => {
|
||||
let finishRun = (): void => undefined
|
||||
let remoteSignal: AbortSignal | undefined
|
||||
@@ -6555,6 +6636,150 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['runtime', 'customization', 'capabilities'] as const)('restores concurrent live questions and their answer history across %s settings reload', async (settingsKind) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-recovered-question-history-'))
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize(directory)
|
||||
const project = database.createSshProject({
|
||||
project: {
|
||||
name: 'Recovered questions', description: '', rootPath: '/srv/project',
|
||||
defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' }
|
||||
},
|
||||
executionSpace: {
|
||||
kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850',
|
||||
remoteRootPath: '/srv/project'
|
||||
},
|
||||
assertCurrent: () => {}
|
||||
})
|
||||
const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
||||
const tasks = [1, 2].map(n => ({
|
||||
taskId: id(810 + n), conversationId: id(820 + n), projectId: project.id,
|
||||
currentUserMessageId: id(830 + n), currentAssistantMessageId: id(840 + n),
|
||||
instructions: 'Original question', workMode: 'execute' as const,
|
||||
status: 'running' as const
|
||||
}))
|
||||
for (const task of tasks) {
|
||||
database.saveLocalConversations([{
|
||||
header: {
|
||||
id: task.conversationId, projectId: project.id,
|
||||
title: 'Recovered', updatedAt: Date.now(), runtimeSelection: { provider: 'opencode' }
|
||||
},
|
||||
messages: []
|
||||
}])
|
||||
database.createTask({
|
||||
id: task.taskId, conversationId: task.conversationId, projectId: project.id,
|
||||
title: 'Recovered question', instructions: task.instructions, workMode: task.workMode,
|
||||
remoteRecovery: {
|
||||
recoverable: true, currentUserMessageId: task.currentUserMessageId,
|
||||
currentAssistantMessageId: task.currentAssistantMessageId
|
||||
}
|
||||
})
|
||||
}
|
||||
const ready = new Set<string>()
|
||||
const signals: AbortSignal[] = []
|
||||
const finish = new Map<string, () => void>()
|
||||
const runtime = {
|
||||
runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true,
|
||||
respondToQuestion: vi.fn(async (questionId: string) => { finish.get(questionId)?.() }),
|
||||
async *run(request: { requestId: string; remoteRecoveryOnly?: boolean }, signal: AbortSignal) {
|
||||
signals.push(signal)
|
||||
expect(request.remoteRecoveryOnly).toBe(true)
|
||||
const answered = new Promise<void>(resolve => { finish.set(request.requestId, resolve) })
|
||||
ready.add(request.requestId)
|
||||
yield {
|
||||
requestId: request.requestId, type: 'question' as const, questionId: request.requestId,
|
||||
questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }]
|
||||
}
|
||||
await answered
|
||||
yield {
|
||||
requestId: request.requestId, type: 'done' as const, sessionId: request.requestId,
|
||||
remoteProvenance: {
|
||||
source: 'remote-semantic-transcript' as const, bindingId: request.requestId,
|
||||
operationId: request.requestId, semanticSequence: '1', eventIndex: 0
|
||||
}
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId, type: 'remote-semantic-checkpoint' as const,
|
||||
remoteProvenance: {
|
||||
source: 'remote-semantic-transcript' as const, bindingId: request.requestId,
|
||||
operationId: request.requestId, semanticSequence: '1', eventIndex: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const { harness } = createManagedSshHarness(runtime)
|
||||
harness.assistantDatabase.getProject.mockReturnValue({
|
||||
id: project.id, runtimeSelection: { provider: 'opencode' }, rootPath: '/srv/project',
|
||||
executionSpace: { kind: 'ssh', hostId: id(850), remoteRootPath: '/srv/project' }
|
||||
})
|
||||
harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database))
|
||||
harness.assistantDatabase.listConversations.mockImplementation(database.listConversations.bind(database))
|
||||
harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database))
|
||||
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
|
||||
harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database))
|
||||
harness.assistantDatabase.getTask.mockImplementation(database.getTask.bind(database))
|
||||
harness.assistantDatabase.recordRemoteTaskQuestionAnswer.mockImplementation(
|
||||
(...args) => database.recordRemoteTaskQuestionAnswer(...args)
|
||||
)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const list = () => electronMocks.handlers.get(ipcChannels.conversationsList)!(event) as ConversationSnapshot[]
|
||||
try {
|
||||
harness.recoveryGetHandler?.(event)
|
||||
await vi.waitFor(() => expect(ready.size).toBe(2))
|
||||
await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true))
|
||||
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
|
||||
const update = settingsKind === 'runtime'
|
||||
? [ipcChannels.runtimeSettingsUpdate, runtimeSettingsInputSchema.strip().parse({
|
||||
...defaultRuntimeSettings, workspacePath: directory, apiKey: { action: 'keep' }
|
||||
})] as const
|
||||
: settingsKind === 'customization'
|
||||
? [ipcChannels.runtimeCustomizationUpdate, { opencode: { defaultAgent: 'build' }, continue: { presets: [] } }] as const
|
||||
: [ipcChannels.capabilitiesToggleWebSearch, false] as const
|
||||
await electronMocks.handlers.get(update[0])!(event, update[1])
|
||||
expect(signals.every(signal => !signal.aborted)).toBe(true)
|
||||
expect(harness.approvalBroker.clear).not.toHaveBeenCalled()
|
||||
for (const [index, task] of [...tasks].reverse().entries()) {
|
||||
const snapshot = list().find(c => c.id === task.conversationId)!
|
||||
expect(snapshot.activeRequest).toMatchObject({
|
||||
requestId: task.taskId, messageId: task.currentAssistantMessageId
|
||||
})
|
||||
await electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!(event, {
|
||||
questionId: task.taskId, answers: index === 0 ? [['Yes']] : []
|
||||
})
|
||||
}
|
||||
await vi.waitFor(() => expect(list().every(c => !c.activeRequest)).toBe(true))
|
||||
expect(runtime.respondToQuestion.mock.calls.map(([questionId]) => questionId))
|
||||
.toEqual([...tasks].reverse().map(task => task.taskId))
|
||||
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledTimes(4)
|
||||
database.close()
|
||||
const reopened = new AssistantDatabase(databasePath)
|
||||
reopened.initialize(directory)
|
||||
try {
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
expect(reopened.getTask(task.taskId).status).toBe('completed')
|
||||
expect(reopened.getConversation(task.conversationId).messages[1]).toMatchObject({
|
||||
id: task.currentAssistantMessageId, state: 'complete',
|
||||
answeredQuestions: [{
|
||||
questionId: task.taskId, skipped: index === 0,
|
||||
questions: [{
|
||||
header: 'Input', question: 'Continue?', options: [], multiple: false, custom: true,
|
||||
...(index === 1 ? { answer: ['Yes'] } : {})
|
||||
}]
|
||||
}]
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reopened.close()
|
||||
}
|
||||
} finally {
|
||||
for (const resolve of finish.values()) resolve()
|
||||
await harness.dispose()
|
||||
database.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts and drains a local run during Desktop shutdown', async () => {
|
||||
let localSignal: AbortSignal | undefined
|
||||
let finishLocalCleanup = (): void => undefined
|
||||
@@ -6627,7 +6852,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
||||
})
|
||||
|
||||
it('keeps an accepted managed SSH run recoverable after user Stop', async () => {
|
||||
it.each([false, true])('recovers user Stop only while its remote result is uncertain (confirmed=%s)', async (confirmed) => {
|
||||
let remoteSignal: AbortSignal | undefined
|
||||
let statusConsumed = false
|
||||
const remoteRuntime = {
|
||||
@@ -6650,6 +6875,9 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
if (confirmed) {
|
||||
throw new RemotePromptCancelledError('请求已取消')
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
yield { requestId: request.requestId, type: 'done' } as const
|
||||
}
|
||||
@@ -6697,11 +6925,22 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
)
|
||||
|
||||
expect(remoteSignal?.aborted).toBe(true)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
if (confirmed) {
|
||||
await vi.waitFor(() => expect(
|
||||
harness.assistantDatabase.endRecoverableRemoteTask
|
||||
).toHaveBeenCalledWith(requestId, '请求已取消', 'cancelled'))
|
||||
expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith(
|
||||
requestId, 'error', expect.anything()
|
||||
)
|
||||
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
||||
ipcChannels.remoteProjectRecoveryProgress,
|
||||
expect.anything()
|
||||
)
|
||||
} else {
|
||||
await vi.waitFor(() => expect(
|
||||
harness.assistantDatabase.updateTaskStatus
|
||||
).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消')
|
||||
)
|
||||
).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消'))
|
||||
}
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
|
||||
+127
-31
@@ -240,7 +240,10 @@ import {
|
||||
} from './agent/runtime-selection'
|
||||
import { safeToolErrorDetail } from './agent/approval-summary'
|
||||
import { ReasoningTagStreamParser } from './agent/reasoning-stream'
|
||||
import { RemotePromptRecoveryUnavailableError } from './agent/acp-remote-runtime'
|
||||
import {
|
||||
RemotePromptCancelledError,
|
||||
RemotePromptRecoveryUnavailableError
|
||||
} from './agent/acp-remote-runtime'
|
||||
import {
|
||||
bundledContinueVersion,
|
||||
bundledDeepSeekHarnessVersion,
|
||||
@@ -1192,7 +1195,7 @@ export function registerIpcHandlers(
|
||||
assistantDatabase: AssistantDatabase,
|
||||
approvalBroker: ToolApprovalBroker,
|
||||
bundledRuntimePaths: BundledRuntimePaths,
|
||||
onRuntimeSettingsChanged: () => Promise<void>,
|
||||
activateRuntimeSettings: () => Promise<void>,
|
||||
onBeforeClearLocalData?: () => Promise<void>,
|
||||
browserControl?: {
|
||||
createTab(
|
||||
@@ -1337,9 +1340,21 @@ export function registerIpcHandlers(
|
||||
controller: AbortController
|
||||
conversationId: string
|
||||
detachOnApplicationExit: boolean
|
||||
teamMode?: boolean
|
||||
recoveredMessageId?: string
|
||||
release(): void
|
||||
}
|
||||
const activeRequests = new Map<string, ActiveRequestLease>()
|
||||
const onRuntimeSettingsChanged = async (): Promise<void> => {
|
||||
// The expert scheduler cancels its work on replacement. Its parent must
|
||||
// not synthesize partial old-generation results with the replacement model.
|
||||
for (const lease of activeRequests.values()) {
|
||||
if (lease.teamMode) {
|
||||
lease.controller.abort(new Error('专家团队使用的模型设置已更改'))
|
||||
}
|
||||
}
|
||||
await activateRuntimeSettings()
|
||||
}
|
||||
const requireTerminalSessionManager = (): TerminalSessionManager => {
|
||||
if (!terminalSessionManager) {
|
||||
throw new Error('终端服务不可用')
|
||||
@@ -1375,7 +1390,7 @@ export function registerIpcHandlers(
|
||||
const activeEventBuffers = new Map<string, { flush(): void }>()
|
||||
const pendingAgentQuestions = new Map<
|
||||
string,
|
||||
{ requestId: string; runtime: AgentRuntime }
|
||||
{ requestId: string; runtime: AgentRuntime; question: Extract<AgentEvent, { type: 'question' }> }
|
||||
>()
|
||||
const heartbeatControllers = new Set<AbortController>()
|
||||
let activeSshDirectoryBrowse: AbortController | undefined
|
||||
@@ -1637,7 +1652,6 @@ export function registerIpcHandlers(
|
||||
): Promise<CapabilitySnapshot> => {
|
||||
const snapshot = await operation
|
||||
if (reconfigureRuntime) {
|
||||
abortActiveRequests('扩展能力设置已更改')
|
||||
await onRuntimeSettingsChanged()
|
||||
}
|
||||
return snapshot
|
||||
@@ -1924,6 +1938,7 @@ export function registerIpcHandlers(
|
||||
controller
|
||||
)
|
||||
lease.detachOnApplicationExit = true
|
||||
lease.recoveredMessageId = task.currentAssistantMessageId
|
||||
let sawTerminal = false
|
||||
try {
|
||||
publishRemoteProjectRecovery({
|
||||
@@ -1973,6 +1988,21 @@ export function registerIpcHandlers(
|
||||
recoveryRequest,
|
||||
controller.signal
|
||||
)) {
|
||||
if (rawEvent.type === 'question' || rawEvent.type === 'question-resolved') {
|
||||
if (rawEvent.type === 'question') {
|
||||
pendingAgentQuestions.set(rawEvent.questionId, {
|
||||
requestId: task.taskId, runtime: recoveredRuntime, question: rawEvent
|
||||
})
|
||||
assistantDatabase.updateTaskStatus(task.taskId, 'waiting_approval')
|
||||
} else {
|
||||
pendingAgentQuestions.delete(rawEvent.questionId)
|
||||
if (![...pendingAgentQuestions.values()].some(pending => pending.requestId === task.taskId)) {
|
||||
assistantDatabase.updateTaskStatus(task.taskId, 'running')
|
||||
}
|
||||
}
|
||||
publishConversationChange()
|
||||
continue
|
||||
}
|
||||
const provenance = remoteSemanticProvenance(rawEvent)
|
||||
if (provenance === undefined) {
|
||||
if (rawEvent.type !== 'status') {
|
||||
@@ -2069,16 +2099,26 @@ export function registerIpcHandlers(
|
||||
return
|
||||
}
|
||||
if (error instanceof RemotePromptRecoveryUnavailableError) {
|
||||
assistantDatabase.failRecoverableRemoteTask(
|
||||
assistantDatabase.endRecoverableRemoteTask(
|
||||
task.taskId,
|
||||
error.message
|
||||
error.message,
|
||||
'failed'
|
||||
)
|
||||
publishConversationChange()
|
||||
return
|
||||
}
|
||||
if (error instanceof RemotePromptCancelledError) {
|
||||
assistantDatabase.endRecoverableRemoteTask(task.taskId, '请求已取消', 'cancelled')
|
||||
publishConversationChange()
|
||||
return
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
for (const [id, pending] of pendingAgentQuestions) {
|
||||
if (pending.requestId === task.taskId) pendingAgentQuestions.delete(id)
|
||||
}
|
||||
lease.release()
|
||||
publishConversationChange()
|
||||
assistantDatabase.completeTaskScheduleRun(task.taskId)
|
||||
}
|
||||
}
|
||||
@@ -2111,9 +2151,11 @@ export function registerIpcHandlers(
|
||||
task.projectId === projectId &&
|
||||
!activeRequests.has(task.taskId)
|
||||
)
|
||||
for (const task of tasks) {
|
||||
await recoverRemoteTask(task, requestId)
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
tasks.map(task => recoverRemoteTask(task, requestId))
|
||||
)
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
// Recovery may terminalize the task without a trailing checkpoint;
|
||||
// let the renderer converge on the persisted terminal state.
|
||||
publishConversationChange()
|
||||
@@ -4137,6 +4179,7 @@ export function registerIpcHandlers(
|
||||
request.conversationId,
|
||||
controller
|
||||
)
|
||||
activeRequestLease.teamMode = request.teamMode === true
|
||||
if (parsedInput.queueItemId) {
|
||||
const dispatchTimeout = queueDispatchTimers.get(
|
||||
parsedInput.queueItemId
|
||||
@@ -4638,7 +4681,8 @@ export function registerIpcHandlers(
|
||||
if (publicEvent.type === 'question') {
|
||||
pendingAgentQuestions.set(publicEvent.questionId, {
|
||||
requestId: request.requestId,
|
||||
runtime: selectedRuntime
|
||||
runtime: selectedRuntime,
|
||||
question: publicEvent
|
||||
})
|
||||
}
|
||||
if (publicEvent.type === 'question-resolved' &&
|
||||
@@ -4723,16 +4767,19 @@ export function registerIpcHandlers(
|
||||
} catch (error) {
|
||||
publishReferences()
|
||||
eventBuffer.flush()
|
||||
const errorMessage = controller.signal.aborted
|
||||
const cancelled =
|
||||
controller.signal.aborted ||
|
||||
error instanceof RemotePromptCancelledError
|
||||
const errorMessage = cancelled
|
||||
? '请求已取消'
|
||||
: safeRuntimeError(error, 'Agent Runtime 执行失败')
|
||||
const agentEvent: AgentEvent =
|
||||
runtimeErrorEvent && !controller.signal.aborted
|
||||
runtimeErrorEvent && !cancelled
|
||||
? runtimeErrorEvent
|
||||
: {
|
||||
requestId: request.requestId,
|
||||
type: 'error',
|
||||
status: controller.signal.aborted
|
||||
status: cancelled
|
||||
? 'cancelled'
|
||||
: 'failed',
|
||||
message: errorMessage
|
||||
@@ -4745,25 +4792,39 @@ export function registerIpcHandlers(
|
||||
const shouldRecoverAcceptedRemoteOperation =
|
||||
remoteConversationRecovery !== undefined &&
|
||||
managedSshOperationAccepted &&
|
||||
!runtimeErrorPersistedRemotely
|
||||
!runtimeErrorPersistedRemotely &&
|
||||
!(error instanceof RemotePromptCancelledError)
|
||||
remoteRecoveryPending =
|
||||
shouldRecoverAcceptedRemoteOperation
|
||||
assistantDatabase.updateTaskStatus(
|
||||
request.requestId,
|
||||
shouldRecoverAcceptedRemoteOperation
|
||||
? 'interrupted'
|
||||
: terminalStatus,
|
||||
errorMessage
|
||||
)
|
||||
if (!runtimeErrorPersistedRemotely) {
|
||||
assistantDatabase.appendTaskEvent(
|
||||
if (
|
||||
remoteConversationRecovery !== undefined &&
|
||||
error instanceof RemotePromptCancelledError &&
|
||||
!runtimeErrorPersistedRemotely
|
||||
) {
|
||||
assistantDatabase.endRecoverableRemoteTask(
|
||||
request.requestId,
|
||||
agentEvent.type,
|
||||
agentEvent
|
||||
errorMessage,
|
||||
'cancelled'
|
||||
)
|
||||
publishConversationChange()
|
||||
} else {
|
||||
assistantDatabase.updateTaskStatus(
|
||||
request.requestId,
|
||||
shouldRecoverAcceptedRemoteOperation
|
||||
? 'interrupted'
|
||||
: terminalStatus,
|
||||
errorMessage
|
||||
)
|
||||
if (!runtimeErrorPersistedRemotely) {
|
||||
assistantDatabase.appendTaskEvent(
|
||||
request.requestId,
|
||||
agentEvent.type,
|
||||
agentEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
showDesktopNotificationWhenUnfocused(window, {
|
||||
title: controller.signal.aborted
|
||||
title: cancelled
|
||||
? 'GoodBuddy 任务已取消'
|
||||
: 'GoodBuddy 任务失败',
|
||||
body: '打开任务工作栏查看详情。'
|
||||
@@ -4874,7 +4935,26 @@ export function registerIpcHandlers(
|
||||
response.questionId,
|
||||
response.answers.length > 0 ? response.answers : undefined
|
||||
)
|
||||
const recorded = assistantDatabase.recordRemoteTaskQuestionAnswer(pending.requestId, {
|
||||
questionId: response.questionId,
|
||||
skipped: response.answers.length === 0,
|
||||
questions: pending.question.questions.map((question, index) => ({
|
||||
...question,
|
||||
answer: response.answers[index]
|
||||
}))
|
||||
})
|
||||
pendingAgentQuestions.delete(response.questionId)
|
||||
if (activeRequests.get(pending.requestId)?.recoveredMessageId) {
|
||||
if (
|
||||
![...pendingAgentQuestions.values()].some(value => value.requestId === pending.requestId) &&
|
||||
assistantDatabase.getTask(pending.requestId).status === 'waiting_approval'
|
||||
) {
|
||||
assistantDatabase.updateTaskStatus(pending.requestId, 'running')
|
||||
}
|
||||
}
|
||||
if (recorded) {
|
||||
publishConversationChange()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5083,8 +5163,6 @@ export function registerIpcHandlers(
|
||||
persistCandidate: async () => {
|
||||
const saved =
|
||||
await settingsStore.updateRuntimeCustomization(settings)
|
||||
abortActiveRequests('Runtime 定制设置已更改')
|
||||
approvalBroker.clear()
|
||||
return saved
|
||||
},
|
||||
activate: onRuntimeSettingsChanged,
|
||||
@@ -5153,8 +5231,6 @@ export function registerIpcHandlers(
|
||||
...settings,
|
||||
workspacePath
|
||||
})
|
||||
abortActiveRequests('运行时设置已更改')
|
||||
approvalBroker.clear()
|
||||
return saved
|
||||
},
|
||||
activate: onRuntimeSettingsChanged,
|
||||
@@ -6624,7 +6700,27 @@ export function registerIpcHandlers(
|
||||
|
||||
registerHandler(ipcChannels.conversationsList, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.listConversations()
|
||||
const questions = new Map<string, Extract<AgentEvent, { type: 'question' }>[]>()
|
||||
for (const pending of pendingAgentQuestions.values()) {
|
||||
const entries = questions.get(pending.requestId) ?? []
|
||||
entries.push(pending.question)
|
||||
questions.set(pending.requestId, entries)
|
||||
}
|
||||
const recovered = new Map(
|
||||
[...activeRequests].flatMap(([requestId, lease]) =>
|
||||
lease.recoveredMessageId
|
||||
? [[lease.conversationId, {
|
||||
requestId,
|
||||
messageId: lease.recoveredMessageId,
|
||||
questions: questions.get(requestId) ?? []
|
||||
}] as const]
|
||||
: []
|
||||
)
|
||||
)
|
||||
return assistantDatabase.listConversations().map(conversation => ({
|
||||
...conversation,
|
||||
...(recovered.has(conversation.id) ? { activeRequest: recovered.get(conversation.id) } : {})
|
||||
}))
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
|
||||
@@ -448,6 +448,36 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('retains active Sessions and admits new channels after same-Agent reconnect', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(fixture.options)
|
||||
let releaseGate!: () => void
|
||||
remoteHarness.runGate = new Promise<void>(resolve => { releaseGate = resolve })
|
||||
const first = runtime.run(request('before-reconnect'), new AbortController().signal)
|
||||
await first.next()
|
||||
fixture.connection.client.generation = 6
|
||||
const second = runtime.run(request('after-reconnect'), new AbortController().signal)
|
||||
try {
|
||||
await expect(second.next()).resolves.toMatchObject({
|
||||
value: { type: 'status' }, done: false
|
||||
})
|
||||
expect(remoteHarness.instances).toHaveLength(1)
|
||||
expect(remoteHarness.instances[0]!.dispose).not.toHaveBeenCalled()
|
||||
// This fixture does not implement the open RPC. Reaching it proves the
|
||||
// original factory accepts the recovered transport instead of a stale lease.
|
||||
await expect(remoteHarness.instances[0]!.options.channelFactory(
|
||||
'binding-after-reconnect'
|
||||
)).rejects.toThrow()
|
||||
expect(fixture.connection.client.request.mock.calls.some(
|
||||
([method]) => method === 'runtime/openAcpChannel'
|
||||
)).toBe(true)
|
||||
} finally {
|
||||
releaseGate()
|
||||
await Promise.all([collect(first), collect(second)])
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('abandons connected runtime ownership on application exit without remote cleanup', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
|
||||
@@ -286,11 +286,12 @@ class ManagedRemoteRuntimeResources {
|
||||
bindingId: string
|
||||
) {
|
||||
const transport = await this.prepareTransport()
|
||||
assertSameTransport(
|
||||
expected,
|
||||
transport,
|
||||
'Remote Runtime transport lease is stale'
|
||||
)
|
||||
if (
|
||||
expected.daemonBootId !== transport.daemonBootId ||
|
||||
transport.controllerGeneration < expected.controllerGeneration
|
||||
) {
|
||||
throw new Error('Remote Runtime transport lease is stale')
|
||||
}
|
||||
const channel = await createProtocolRemoteRuntimeChannel({
|
||||
connection: this.connection,
|
||||
openIdentity: {
|
||||
@@ -302,7 +303,7 @@ class ManagedRemoteRuntimeResources {
|
||||
})
|
||||
try {
|
||||
assertSameTransport(
|
||||
expected,
|
||||
transport,
|
||||
this.currentTransportIdentity(),
|
||||
'Remote Agent transport changed while opening an ACP channel'
|
||||
)
|
||||
@@ -511,19 +512,24 @@ class ManagedRemoteAcpRuntime implements AgentRuntime {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
if (!sameTransport(this.transport, currentTransport)) {
|
||||
if (this.#activeRuns > 0) {
|
||||
throw new Error(
|
||||
'Remote Agent transport changed while another Runtime request was active'
|
||||
)
|
||||
const resumedSameAgent =
|
||||
this.transport.daemonBootId === currentTransport.daemonBootId &&
|
||||
currentTransport.controllerGeneration > this.transport.controllerGeneration
|
||||
if (!resumedSameAgent) {
|
||||
if (this.#activeRuns > 0) {
|
||||
throw new Error(
|
||||
'Remote Agent transport changed while another Runtime request was active'
|
||||
)
|
||||
}
|
||||
await this.remote.dispose()
|
||||
if (this.#disposed) {
|
||||
throw new Error('远端 Runtime 已关闭')
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
this.remote = this.createRemote(currentTransport)
|
||||
}
|
||||
await this.remote.dispose()
|
||||
if (this.#disposed) {
|
||||
throw new Error('远端 Runtime 已关闭')
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
this.remote = this.createRemote(currentTransport)
|
||||
this.transport = currentTransport
|
||||
}
|
||||
this.#activeRuns += 1
|
||||
|
||||
@@ -1296,7 +1296,7 @@ describe('ProtocolRemoteRuntimeChannel', () => {
|
||||
return {
|
||||
status: 'terminal',
|
||||
terminalState: 'failed',
|
||||
processTree: 'running'
|
||||
processTree: 'unknown'
|
||||
}
|
||||
}
|
||||
return defaultResponse(method, params)
|
||||
@@ -1311,6 +1311,17 @@ describe('ProtocolRemoteRuntimeChannel', () => {
|
||||
await fixture.channel.close()
|
||||
})
|
||||
|
||||
it.each(['failed', 'cancelled'] as const)('accepts a %s Session while its shared process serves peers', async terminalState => {
|
||||
const fixture = await openChannel()
|
||||
fixture.client.responder = (method, params) => method === 'runtime/reconcilePrompt'
|
||||
? { status: 'terminal', terminalState, processTree: 'running' }
|
||||
: defaultResponse(method, params)
|
||||
await expect(fixture.channel.reconcilePromptOperation({
|
||||
bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1'
|
||||
})).resolves.toEqual({ status: 'terminal', terminalState, processTree: 'running' })
|
||||
await fixture.channel.close()
|
||||
})
|
||||
|
||||
it('keeps streams alive, seeds exact sequences, and ACKs replay duplicates without re-emitting them', async () => {
|
||||
const fixture = await openChannel()
|
||||
const recoveryAbort = new AbortController()
|
||||
|
||||
@@ -1436,6 +1436,19 @@ function persistedTerminalStateOverridesLocal(
|
||||
return persisted.state !== "streaming" && local.state !== "complete";
|
||||
}
|
||||
|
||||
function withRecoveredQuestions(conversation: Conversation): Conversation {
|
||||
const active = conversation.activeRequest;
|
||||
if (!active) return conversation;
|
||||
return {
|
||||
...conversation,
|
||||
messages: conversation.messages.map(message =>
|
||||
message.id === active.messageId && message.state === "streaming"
|
||||
? { ...message, pendingQuestions: active.questions }
|
||||
: message,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function mergePersistedConversations(
|
||||
current: readonly Conversation[],
|
||||
incoming: readonly ConversationSnapshot[],
|
||||
@@ -1454,7 +1467,7 @@ function mergePersistedConversations(
|
||||
const local = currentById.get(conversation.id);
|
||||
if (!local || local.remote) {
|
||||
persistedLocal.set(conversation.id, conversation);
|
||||
return conversation;
|
||||
return withRecoveredQuestions(conversation);
|
||||
}
|
||||
const localMessageById = new Map(
|
||||
local.messages.map((message) => [message.id, message]),
|
||||
@@ -1466,6 +1479,9 @@ function mergePersistedConversations(
|
||||
const messages = [
|
||||
...conversation.messages.map((message) => {
|
||||
const localMessage = localMessageById.get(message.id);
|
||||
if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) {
|
||||
return { ...message, pendingQuestions: undefined };
|
||||
}
|
||||
if (!localIsNewer) {
|
||||
// Pending prompts are live-only and are omitted from persisted messages.
|
||||
if (localMessage?.state === "streaming" && message.state === "streaming") {
|
||||
@@ -1484,10 +1500,10 @@ function mergePersistedConversations(
|
||||
...local.messages.filter((message) => !serverMessageIds.has(message.id)),
|
||||
];
|
||||
const next = localIsNewer
|
||||
? { ...local, messages }
|
||||
? { ...local, messages, activeRequest: conversation.activeRequest }
|
||||
: { ...conversation, messages };
|
||||
persistedLocal.set(conversation.id, conversation);
|
||||
return next;
|
||||
return withRecoveredQuestions(next);
|
||||
});
|
||||
for (const conversation of current) {
|
||||
if (!incomingById.has(conversation.id)) {
|
||||
@@ -5579,7 +5595,7 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
: [];
|
||||
let nextConversations: Conversation[] = [
|
||||
...persistedConversations,
|
||||
...persistedConversations.map(withRecoveredQuestions),
|
||||
...migratedLocalConversations,
|
||||
];
|
||||
let projectConversation = nextConversations.find(
|
||||
@@ -7404,7 +7420,9 @@ function App(): React.JSX.Element {
|
||||
const stop = async (): Promise<void> => {
|
||||
const requestId = [...activeRuns.current.entries()].find(
|
||||
([, run]) => run.conversationId === activeId,
|
||||
)?.[0];
|
||||
)?.[0] ?? conversationsRef.current.find(
|
||||
conversation => conversation.id === activeId,
|
||||
)?.activeRequest?.requestId;
|
||||
if (requestId) {
|
||||
try {
|
||||
await window.goodbuddy.agent.cancel(requestId);
|
||||
@@ -7503,8 +7521,7 @@ function App(): React.JSX.Element {
|
||||
if (!question || question.questionId !== questionId) {
|
||||
return;
|
||||
}
|
||||
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
|
||||
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
|
||||
const taskId = question.requestId;
|
||||
await window.goodbuddy.agent.respondQuestion(questionId, answers);
|
||||
updateMessage(conversationId, messageId, (message) => {
|
||||
const pendingQuestions = message.pendingQuestions?.filter((item) => item.questionId !== questionId);
|
||||
|
||||
@@ -429,20 +429,8 @@ export const acpReconcilePromptResultSchema = z.discriminatedUnion(
|
||||
]),
|
||||
processTree: z.enum(['running', 'empty'])
|
||||
})
|
||||
.strict()
|
||||
.superRefine((result, context) => {
|
||||
if (
|
||||
result.processTree === 'running' &&
|
||||
result.terminalState !== 'completed'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['processTree'],
|
||||
message:
|
||||
'Only a completed prompt may retain a running process tree'
|
||||
})
|
||||
}
|
||||
}),
|
||||
// A terminal Session can leave a shared Runtime serving other Sessions.
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
status: z.literal('running'),
|
||||
|
||||
@@ -38,6 +38,23 @@ const untouchedProject: AssistantProject = {
|
||||
}
|
||||
|
||||
describe('agent question contracts', () => {
|
||||
it('returns recovered questions only in live snapshots, not saved headers', () => {
|
||||
const id = '00000000-0000-4000-8000-000000000901'
|
||||
const snapshot = {
|
||||
id, title: 'Recovered', updatedAt: 1, messages: [],
|
||||
activeRequest: {
|
||||
requestId: id, messageId: id,
|
||||
questions: [{
|
||||
requestId: id, type: 'question', questionId: 'native',
|
||||
questions: [{ header: 'Input', question: 'Which?', options: [], multiple: false, custom: true }]
|
||||
}]
|
||||
}
|
||||
}
|
||||
expect(conversationSnapshotSchema.parse(snapshot)).toEqual(snapshot)
|
||||
const { messages, ...header } = snapshot
|
||||
expect(localConversationSaveBatchSchema.safeParse([{ header, messages }]).success).toBe(false)
|
||||
})
|
||||
|
||||
it('does not impose GoodBuddy-specific question answer limits', () => {
|
||||
const answers = Array.from({ length: 5 }, (_, questionIndex) =>
|
||||
Array.from(
|
||||
|
||||
@@ -515,6 +515,21 @@ export const conversationBranchSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const liveAgentQuestionSchema = z.object({
|
||||
requestId: z.string().min(1),
|
||||
type: z.literal('question'),
|
||||
questionId: z.string().min(1),
|
||||
childTaskId: z.string().optional(),
|
||||
questions: z.array(z.object({
|
||||
header: z.string(),
|
||||
question: z.string(),
|
||||
options: z.array(z.object({ label: z.string(), description: z.string() })),
|
||||
multiple: z.boolean(),
|
||||
custom: z.boolean()
|
||||
}))
|
||||
})
|
||||
export type LiveAgentQuestion = z.infer<typeof liveAgentQuestionSchema>
|
||||
|
||||
export const conversationSnapshotSchema = z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
@@ -537,6 +552,12 @@ export const conversationSnapshotSchema = z
|
||||
branch: conversationBranchSchema.optional(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
updatedAt: z.number().int().nonnegative(),
|
||||
// Main-only live projection. Never part of a saved conversation header.
|
||||
activeRequest: z.object({
|
||||
requestId: assistantIdSchema,
|
||||
messageId: assistantIdSchema,
|
||||
questions: z.array(liveAgentQuestionSchema)
|
||||
}).optional(),
|
||||
messages: z.array(conversationMessageSchema)
|
||||
})
|
||||
.strict()
|
||||
@@ -551,7 +572,8 @@ export const conversationSnapshotsSchema = z
|
||||
export const localConversationHeaderSchema = conversationSnapshotSchema
|
||||
.omit({
|
||||
messages: true,
|
||||
remote: true
|
||||
remote: true,
|
||||
activeRequest: true
|
||||
})
|
||||
|
||||
export type LocalConversationHeader = z.infer<
|
||||
|
||||
Reference in New Issue
Block a user