feat: unify execution and add workspace file imports
Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content. Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation. Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit. Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
This commit is contained in:
@@ -87,13 +87,12 @@ export class AgentAcpConnection {
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
socketPath?: string,
|
||||
workMode?: 'ask' | 'execute',
|
||||
imageToolName?: string
|
||||
): 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, imageToolName } : { release: true }) }),
|
||||
body: JSON.stringify({ sessionId, operationId, ...(socketPath ? { socketPath, imageToolName } : { release: true }) }),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`Shared model bridge route failed (${response.status})`)
|
||||
|
||||
@@ -47,7 +47,7 @@ it.skipIf(!existsSync(binary))('keeps a real OpenCode answer completed after a d
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: '1', OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1',
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: '1', OPENCODE_DISABLE_SHARE: '1',
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: 'openai-chat-completions', model: 'local-ack', loopbackOrigin: origin, workMode: 'ask'
|
||||
protocol: 'openai-chat-completions', model: 'local-ack', loopbackOrigin: origin
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -78,7 +78,7 @@ it.skipIf(!existsSync(binary))('keeps a real OpenCode answer completed after a d
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await owner.start({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
|
||||
prompt: [{ type: 'text', text: 'Reply ACK_OK without using tools.' }] }, 'ask')
|
||||
prompt: [{ type: 'text', text: 'Reply ACK_OK without using tools.' }] })
|
||||
await completion
|
||||
})(),
|
||||
new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(new Error('Local ACP completion timed out')), 20_000) })
|
||||
|
||||
@@ -23,7 +23,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentOwnedAcpPrompt', () => {
|
||||
it('applies each prompt mode to the retained session and its permission decisions', async () => {
|
||||
it('refreshes prompt tools on the retained session and authorizes execution', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-'))
|
||||
temporary.push(root)
|
||||
const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite'))
|
||||
@@ -49,8 +49,8 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (const [index, workMode] of (['execute', 'execute', 'ask', 'execute', 'ask'] as const).entries()) {
|
||||
imageUrl = workMode === 'execute' && index !== 1 ? `http://127.0.0.1:1234/prompt-${index}` : undefined
|
||||
for (let index = 0; index < 5; index++) {
|
||||
imageUrl = index === 0 || index === 3 ? `http://127.0.0.1:1234/prompt-${index}` : undefined
|
||||
const operationId = `operation-${index}`
|
||||
transcript.prepare({
|
||||
bindingId: 'binding-1', operationId, requestId: operationId,
|
||||
@@ -59,9 +59,9 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
})
|
||||
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)
|
||||
prompt: [{ type: 'text', text: 'Continue' }]
|
||||
})
|
||||
expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId)
|
||||
if (index !== 2) {
|
||||
expect(index === 0 ? newSession : resumeSession).toHaveBeenLastCalledWith(expect.objectContaining({ mcpServers: mcpServers() }))
|
||||
}
|
||||
@@ -74,7 +74,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
]
|
||||
})
|
||||
expect(result.outcome).toEqual({
|
||||
outcome: 'selected', optionId: workMode === 'execute' ? 'allow' : 'reject'
|
||||
outcome: 'selected', optionId: 'allow'
|
||||
})
|
||||
finish({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => expect(transcript.attach('binding-1', operationId, 'controller-1'))
|
||||
@@ -166,7 +166,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text' as const, text: 'finish independently' }]
|
||||
}
|
||||
await expect(owner.start(request, 'execute')).resolves.toMatchObject({
|
||||
await expect(owner.start(request)).resolves.toMatchObject({
|
||||
state: 'running',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
@@ -206,7 +206,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
limit: 10
|
||||
}).events.map((event) => event.kind)
|
||||
).toEqual(['session-update', 'prompt-terminal'])
|
||||
await expect(owner.start(request, 'execute')).resolves.toMatchObject({
|
||||
await expect(owner.start(request)).resolves.toMatchObject({
|
||||
state: 'completed'
|
||||
})
|
||||
expect(prompt).toHaveBeenCalledOnce()
|
||||
@@ -255,7 +255,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')
|
||||
@@ -324,7 +324,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'wait for Runtime exit' }]
|
||||
}, 'execute')
|
||||
})
|
||||
await process.emitExit()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
@@ -360,7 +360,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
transcript.close()
|
||||
})
|
||||
|
||||
it('answers permission requests autonomously from the accepted work mode', async () => {
|
||||
it('answers permission requests autonomously for the accepted operation', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
|
||||
temporary.push(root)
|
||||
mkdirSync(join(root, 'state'), { mode: 0o700 })
|
||||
@@ -402,7 +402,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'run one tool' }]
|
||||
}, 'execute')
|
||||
})
|
||||
await expect(
|
||||
client.requestPermission({
|
||||
sessionId: 'session-1',
|
||||
@@ -432,7 +432,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
transcript.close()
|
||||
})
|
||||
|
||||
it('allows only one-shot reads while Ask is detached', async () => {
|
||||
it('allows reads and edits while detached', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
|
||||
temporary.push(root)
|
||||
mkdirSync(join(root, 'state'), { mode: 0o700 })
|
||||
@@ -474,7 +474,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] => ({
|
||||
@@ -504,7 +504,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
outcome: { outcome: 'selected', optionId: 'read-allow' }
|
||||
})
|
||||
await expect(client.requestPermission(permission('edit'))).resolves.toEqual({
|
||||
outcome: { outcome: 'selected', optionId: 'edit-reject' }
|
||||
outcome: { outcome: 'selected', optionId: 'edit-allow' }
|
||||
})
|
||||
transcript.close()
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ export type AgentOwnedAcpPromptOptions = {
|
||||
expectedModel?: string
|
||||
process: RuntimeAcpProcessOwner
|
||||
transport?: AgentAcpConnection
|
||||
prepareSession?: (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => Promise<void>
|
||||
prepareSession?: (sessionId: string, operationId: string) => Promise<void>
|
||||
transcript: SemanticPromptStore
|
||||
completePrompt: (
|
||||
operationId: string,
|
||||
@@ -70,7 +70,6 @@ export class AgentOwnedAcpPrompt {
|
||||
#acceptQuestions = false
|
||||
#active?: {
|
||||
operationId: string
|
||||
workMode: 'ask' | 'execute'
|
||||
}
|
||||
|
||||
constructor(options: AgentOwnedAcpPromptOptions) {
|
||||
@@ -85,8 +84,7 @@ export class AgentOwnedAcpPrompt {
|
||||
get sessionId(): string | undefined { return this.#sessionId }
|
||||
|
||||
async start(
|
||||
request: RemoteOwnedPromptStartRequest,
|
||||
workMode: 'ask' | 'execute'
|
||||
request: RemoteOwnedPromptStartRequest
|
||||
): Promise<RemoteOwnedPromptStartResult> {
|
||||
if (
|
||||
request.bindingId !== this.#options.bindingId
|
||||
@@ -198,10 +196,9 @@ export class AgentOwnedAcpPrompt {
|
||||
}))
|
||||
}
|
||||
this.#hasMcpServers = mcpServers.length > 0
|
||||
await this.#options.prepareSession?.(this.#sessionId!, request.operationId, workMode)
|
||||
await this.#options.prepareSession?.(this.#sessionId!, request.operationId)
|
||||
this.#active = {
|
||||
operationId: request.operationId,
|
||||
workMode
|
||||
operationId: request.operationId
|
||||
}
|
||||
this.#acceptQuestions = true
|
||||
const begun = this.#options.transcript.begin({
|
||||
@@ -357,13 +354,8 @@ export class AgentOwnedAcpPrompt {
|
||||
): RequestPermissionResponse {
|
||||
const active = this.#requireActive()
|
||||
const selected =
|
||||
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_once') ??
|
||||
request.options.find((option) => option.kind === 'reject_always')
|
||||
request.options.find((option) => option.kind === 'allow_once') ??
|
||||
request.options.find((option) => option.kind === 'allow_always')
|
||||
const response: RequestPermissionResponse =
|
||||
selected === undefined
|
||||
? { outcome: { outcome: 'cancelled' } }
|
||||
@@ -414,7 +406,7 @@ export class AgentOwnedAcpPrompt {
|
||||
return await Promise.race([operation, this.#processExit])
|
||||
}
|
||||
|
||||
#requireActive(): { operationId: string; workMode: 'ask' | 'execute' } {
|
||||
#requireActive(): { operationId: string } {
|
||||
if (this.#active === undefined) {
|
||||
throw new Error('ACP notification has no active prompt')
|
||||
}
|
||||
|
||||
@@ -58,8 +58,6 @@ describe('Agent CLI fixed command contract', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode'
|
||||
],
|
||||
@@ -71,7 +69,6 @@ describe('Agent CLI fixed command contract', () => {
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
supportsImageInput: false,
|
||||
workMode: 'ask',
|
||||
opencodeEntrypoint: '/runtime/bin/opencode'
|
||||
})
|
||||
await expect(
|
||||
@@ -90,8 +87,6 @@ describe('Agent CLI fixed command contract', () => {
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode',
|
||||
'--api-key',
|
||||
'secret'
|
||||
],
|
||||
{ runModelBridgeHelper, io: cliIo() }
|
||||
)
|
||||
|
||||
@@ -189,7 +189,6 @@ async function runModelBridgeHelper(
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'shared-sessions',
|
||||
'runtime-id',
|
||||
'continue-entrypoint',
|
||||
@@ -199,8 +198,7 @@ async function runModelBridgeHelper(
|
||||
'socket-path',
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode'
|
||||
'supports-image-input'
|
||||
])
|
||||
const protocol = options.protocol
|
||||
if (
|
||||
@@ -214,10 +212,6 @@ async function runModelBridgeHelper(
|
||||
if (imageInput !== 'true' && imageInput !== 'false') {
|
||||
throw new Error('Invalid model bridge image-input option')
|
||||
}
|
||||
const workMode = options['work-mode']
|
||||
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')
|
||||
}
|
||||
@@ -225,7 +219,7 @@ async function runModelBridgeHelper(
|
||||
requireOptions(options, ['continue-entrypoint'])
|
||||
return runContinueAcpHelper({
|
||||
socketPath: options['socket-path']!, protocol, model: options.model!,
|
||||
supportsImageInput: imageInput === 'true', workMode,
|
||||
supportsImageInput: imageInput === 'true',
|
||||
sharedSessions: options['shared-sessions'] === 'true', entrypoint: options['continue-entrypoint']!
|
||||
})
|
||||
}
|
||||
@@ -239,7 +233,6 @@ async function runModelBridgeHelper(
|
||||
protocol: protocol as ModelBridgeProtocol,
|
||||
model: options.model!,
|
||||
supportsImageInput: imageInput === 'true',
|
||||
workMode,
|
||||
...(options['shared-sessions'] === 'true' ? { sharedSessions: true } : {}),
|
||||
opencodeEntrypoint: options['opencode-entrypoint']!
|
||||
})
|
||||
|
||||
@@ -8,8 +8,7 @@ import { runContinueAcpHelper } from './continue-acp-helper'
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
agent: undefined as Agent | undefined,
|
||||
close: () => {},
|
||||
mode: 'execute' as 'ask' | 'execute'
|
||||
close: () => {}
|
||||
}))
|
||||
vi.mock('@agentclientprotocol/sdk', () => ({
|
||||
PROTOCOL_VERSION: 1,
|
||||
@@ -46,18 +45,19 @@ const server = (name: string) => ({
|
||||
|
||||
// Keep the actual helper and adapter config builder together: mocking the
|
||||
// adapter constructor would miss the model-profile configuration-loss bug.
|
||||
it('delivers current ACP session MCP capabilities in Execute and Ask with read-only Ask authorization', async () => {
|
||||
it('delivers current ACP session MCP capabilities with normal tool authorization', async () => {
|
||||
const configs: Array<Record<string, unknown>> = []
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
|
||||
operationId: 'operation', workMode: transport.mode
|
||||
operationId: 'operation'
|
||||
})))
|
||||
vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async function (
|
||||
this: ContinueHostAdapter, _prompt, _signal, authorize, options
|
||||
) {
|
||||
if (options?.workMode === 'ask') {
|
||||
expect(await authorize!({ toolName: 'story_graph_search' } as never)).toBe('once')
|
||||
expect(await authorize!({ toolName: 'generate_image' } as never)).toBe('deny')
|
||||
expect(await authorize!({ toolName: 'write_file' } as never)).toBe('deny')
|
||||
expect(options).not.toHaveProperty('workMode')
|
||||
// Native chat mode adds --readonly independently of tool authorization.
|
||||
expect((this as unknown as { options: { mode: string } }).options.mode).toBe('agent')
|
||||
for (const toolName of ['story_graph_search', 'generate_image', 'write_file', 'dynamic_plugin_tool']) {
|
||||
expect(await authorize!({ toolName } as never)).toBe('once')
|
||||
}
|
||||
const path = await (this as unknown as {
|
||||
createRunConfig(options: ContinueHostRunOptions): Promise<string>
|
||||
@@ -68,7 +68,7 @@ it('delivers current ACP session MCP capabilities in Execute and Ask with read-o
|
||||
})
|
||||
const running = runContinueAcpHelper({
|
||||
socketPath: 'unused', protocol: 'openai-chat-completions', model: 'test-model',
|
||||
supportsImageInput: false, workMode: 'execute', sharedSessions: true, entrypoint: 'unused'
|
||||
supportsImageInput: false, sharedSessions: true, entrypoint: 'unused'
|
||||
})
|
||||
try {
|
||||
await vi.waitFor(() => expect(transport.agent).toBeDefined())
|
||||
@@ -78,9 +78,7 @@ it('delivers current ACP session MCP capabilities in Execute and Ask with read-o
|
||||
await prompt()
|
||||
await agent.resumeSession!({ sessionId, cwd: tmpdir(), mcpServers: [server('replacement')] })
|
||||
await prompt()
|
||||
transport.mode = 'ask'
|
||||
await prompt()
|
||||
transport.mode = 'execute'
|
||||
await agent.loadSession!({ sessionId, cwd: tmpdir(), mcpServers: [] })
|
||||
await prompt()
|
||||
expect(configs.map(config => config.mcpServers ?? [])).toEqual([
|
||||
@@ -98,16 +96,16 @@ it('delivers current ACP session MCP capabilities in Execute and Ask with read-o
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps one flat transcript without nesting history or repeating work-mode text', async () => {
|
||||
it('keeps one flat transcript and preserves user text beginning with Work mode', async () => {
|
||||
const prompts: string[] = []
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ operationId: 'operation', workMode: 'execute' })))
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ operationId: 'operation' })))
|
||||
vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async (prompt) => {
|
||||
prompts.push(prompt)
|
||||
return { text: `answer ${prompts.length}` }
|
||||
})
|
||||
const running = runContinueAcpHelper({
|
||||
socketPath: 'unused', protocol: 'openai-chat-completions', model: 'test-model',
|
||||
supportsImageInput: false, workMode: 'execute', sharedSessions: true, entrypoint: 'unused'
|
||||
supportsImageInput: false, sharedSessions: true, entrypoint: 'unused'
|
||||
})
|
||||
try {
|
||||
await vi.waitFor(() => expect(transport.agent).toBeDefined())
|
||||
@@ -124,17 +122,17 @@ it('keeps one flat transcript without nesting history or repeating work-mode tex
|
||||
|
||||
expect(prompts[0]!.match(/<conversation-history>/g)).toHaveLength(1)
|
||||
expect(prompts[1]!.match(/<conversation-history>/g)).toHaveLength(1)
|
||||
expect(prompts[1]!.match(/Work mode:/g)).toHaveLength(1)
|
||||
expect(prompts[1]!.match(/Work mode:/g)).toHaveLength(2)
|
||||
const history = JSON.parse(prompts[1]!.match(/<conversation-history>(.*)<\/conversation-history>/)![1]!)
|
||||
expect(history).toEqual([...desktopHistory,
|
||||
{ role: 'user', content: 'first' }, { role: 'assistant', content: 'answer 1' }])
|
||||
{ role: 'user', content: 'Work mode: Execute. Follow the user request.\n\nfirst' }, { role: 'assistant', content: 'answer 1' }])
|
||||
} finally {
|
||||
transport.close()
|
||||
await running
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps local profile MCP scoping and includes Main-bound session servers in Ask', async () => {
|
||||
it('keeps local profile MCP scoping and includes Main-bound session servers', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cn-scope-'))
|
||||
try {
|
||||
const configPath = join(root, 'native.json')
|
||||
@@ -147,11 +145,11 @@ it('keeps local profile MCP scoping and includes Main-bound session servers in A
|
||||
const create = (options: ContinueHostRunOptions) => (adapter as unknown as {
|
||||
createRunConfig(options: ContinueHostRunOptions): Promise<string>
|
||||
}).createRunConfig(options)
|
||||
for (const workMode of ['ask', 'execute'] as const) {
|
||||
const path = await create({ workMode })
|
||||
{
|
||||
const path = await create({})
|
||||
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([])
|
||||
}
|
||||
const path = await create({ workMode: 'ask', sessionMcpServers: [{
|
||||
const path = await create({ sessionMcpServers: [{
|
||||
name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} }
|
||||
}] })
|
||||
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers).toEqual([{
|
||||
|
||||
@@ -9,20 +9,17 @@ import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Agent, type M
|
||||
import { ContinueHostAdapter } from '../main/agent/continue-host-adapter'
|
||||
import {
|
||||
promptWithUntrustedConversationHistory,
|
||||
splitUntrustedConversationHistory,
|
||||
stripWorkModeInstruction
|
||||
splitUntrustedConversationHistory
|
||||
} from '../main/agent/runtime-conversation-history'
|
||||
import type { AgentExecutionRequest, AgentImage } from '../main/agent/runtime'
|
||||
import { createUnixModelBridgeExchange } from './model-bridge-broker'
|
||||
import { ModelBridgeLoopbackProxy, MODEL_BRIDGE_SDK_AUTH_SENTINEL, openCodeModelBridgeModelId, type ModelBridgeProtocol } from './model-bridge-helper'
|
||||
import { isStoryGraphTool } from '../shared/story-graph-tools'
|
||||
|
||||
export async function runContinueAcpHelper(options: {
|
||||
socketPath: string
|
||||
protocol: ModelBridgeProtocol
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
sharedSessions?: boolean
|
||||
entrypoint: string
|
||||
}): Promise<number> {
|
||||
@@ -90,16 +87,15 @@ export async function runContinueAcpHelper(options: {
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
}).then(async response => {
|
||||
if (!response.ok) throw new Error('Continue model route is unavailable')
|
||||
return await response.json() as { operationId: string; workMode: 'ask' | 'execute' }
|
||||
return await response.json() as { operationId: string }
|
||||
}) : undefined
|
||||
const workMode = route?.workMode ?? options.workMode
|
||||
const sessionMcpServers = session.mcpServers.map(server => {
|
||||
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
|
||||
return { name: server.name, type: 'streamable-http' as const, url: server.url,
|
||||
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
|
||||
})
|
||||
const adapter = new ContinueHostAdapter({
|
||||
binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'chat',
|
||||
binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'agent',
|
||||
modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model,
|
||||
protocol: options.protocol, authentication: 'api-key', apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||
baseUrl: `${origin}/v1`, supportsImageInput: options.supportsImageInput,
|
||||
@@ -121,8 +117,8 @@ export async function runContinueAcpHelper(options: {
|
||||
session.active = { abort, adapter, finished: new Promise(resolve => { finish = resolve }) }
|
||||
try {
|
||||
const result = await adapter.run(promptWithUntrustedConversationHistory({ prompt: text, history: session.history }, true), abort.signal,
|
||||
async approval => workMode === 'execute' || isStoryGraphTool(approval.toolName ?? '') ? 'once' : 'deny', {
|
||||
workMode, images, sessionMcpServers,
|
||||
async () => 'once', {
|
||||
images, sessionMcpServers,
|
||||
onEvent: async event => {
|
||||
if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: {
|
||||
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: event.delta }
|
||||
@@ -152,7 +148,7 @@ export async function runContinueAcpHelper(options: {
|
||||
if (!result.streamedText && result.text) await connection.sessionUpdate({ sessionId, update: {
|
||||
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: result.text }
|
||||
} })
|
||||
session.history.push({ role: 'user', content: stripWorkModeInstruction(text) },
|
||||
session.history.push({ role: 'user', content: text },
|
||||
{ role: 'assistant', content: result.text })
|
||||
return { stopReason: 'end_turn', ...(result.usage ? { usage: {
|
||||
inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens,
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -25,6 +26,29 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('Agent diagnostic log', () => {
|
||||
it('removes only historical owned top-level modes while holding the append lock', async () => {
|
||||
const stateDirectory = temporaryStateDirectory()
|
||||
const first = new AgentDiagnosticLog(stateDirectory)
|
||||
first.record('daemon.ready')
|
||||
await first.dispose()
|
||||
const record = { ...JSON.parse(readFileSync(first.currentFilePath, 'utf8')),
|
||||
workMode: 'plan', detail: { workMode: 'user-data' }, outcome: 'completed' }
|
||||
const malformed = '{not-json}\n'
|
||||
for (const suffix of ['', '.1', '.2']) {
|
||||
writeFileSync(`${first.currentFilePath}${suffix}`, `${JSON.stringify(record)}\n${malformed}`, { mode: 0o600 })
|
||||
}
|
||||
const restarted = new AgentDiagnosticLog(stateDirectory)
|
||||
restarted.record('daemon.starting')
|
||||
await restarted.dispose()
|
||||
for (const suffix of ['', '.1', '.2']) {
|
||||
const raw = readFileSync(`${first.currentFilePath}${suffix}`, 'utf8')
|
||||
const cleaned = JSON.parse(raw.split('\n')[0]!)
|
||||
const expected = { ...record } as Partial<typeof record>
|
||||
delete expected.workMode
|
||||
expect(cleaned).toEqual(expected)
|
||||
expect(raw).toContain(malformed)
|
||||
}
|
||||
})
|
||||
it('rotates within fixed file and byte bounds and remains readable after restart', async () => {
|
||||
const stateDirectory = temporaryStateDirectory()
|
||||
let now = Date.UTC(2026, 7, 31)
|
||||
@@ -107,7 +131,6 @@ describe('Agent diagnostic log', () => {
|
||||
|
||||
log.record('runtime.start.failed', {
|
||||
runtimeId: 'opencode',
|
||||
workMode: 'execute',
|
||||
error
|
||||
})
|
||||
await log.flush()
|
||||
@@ -143,7 +166,6 @@ describe('Agent diagnostic log', () => {
|
||||
event: 'runtime.start.failed',
|
||||
pid: 42,
|
||||
runtimeId: 'opencode',
|
||||
workMode: 'execute',
|
||||
error: {
|
||||
name: 'Error'
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
ensurePrivateDirectory,
|
||||
ensurePrivateDirectoryTree,
|
||||
readPrivateFile,
|
||||
unlinkOwnedPrivateFile
|
||||
unlinkOwnedPrivateFile,
|
||||
writePrivateFileAtomic
|
||||
} from './managed-paths'
|
||||
|
||||
export const AGENT_DIAGNOSTIC_DIRECTORY_NAME = 'diagnostics'
|
||||
@@ -58,7 +59,6 @@ export type AgentDiagnosticRecord = {
|
||||
daemonBootId?: string
|
||||
reason?: string
|
||||
runtimeId?: string
|
||||
workMode?: 'ask' | 'execute'
|
||||
outcome?: 'completed' | 'failed' | 'cancelled' | 'interrupted' | 'outcome-unknown'
|
||||
error?: {
|
||||
name: string
|
||||
@@ -70,7 +70,6 @@ export type AgentDiagnosticInput = {
|
||||
daemonBootId?: string
|
||||
reason?: string
|
||||
runtimeId?: string
|
||||
workMode?: 'ask' | 'execute'
|
||||
outcome?: AgentDiagnosticRecord['outcome']
|
||||
error?: unknown
|
||||
}
|
||||
@@ -102,6 +101,7 @@ export class AgentDiagnosticLog {
|
||||
#pendingRecordCount = 0
|
||||
#drainPromise?: Promise<void>
|
||||
#disposed = false
|
||||
#historyCleaned = false
|
||||
|
||||
constructor(
|
||||
stateDirectoryInput: string,
|
||||
@@ -207,9 +207,6 @@ export class AgentDiagnosticLog {
|
||||
...optionalToken('daemonBootId', input.daemonBootId, 128),
|
||||
...optionalToken('reason', input.reason, 96),
|
||||
...optionalToken('runtimeId', input.runtimeId, 64),
|
||||
...(input.workMode === undefined
|
||||
? {}
|
||||
: { workMode: input.workMode }),
|
||||
...(input.outcome === undefined
|
||||
? {}
|
||||
: { outcome: input.outcome }),
|
||||
@@ -287,6 +284,22 @@ export class AgentDiagnosticLog {
|
||||
resolve(this.directoryPath, AGENT_DIAGNOSTIC_LOCK_FILE_NAME)
|
||||
)
|
||||
try {
|
||||
if (!this.#historyCleaned) {
|
||||
// The same lock protects append and rotation in every Agent process.
|
||||
for (let index = 0; index < this.#fileCount; index++) {
|
||||
const path = rotatedPath(this.currentFilePath, index)
|
||||
if (await privateFileSize(path) === 0) continue
|
||||
const original = readPrivateFile(path, this.#maximumFileBytes).toString('utf8')
|
||||
const cleaned = original.split('\n').map(line => {
|
||||
const record = parseDiagnosticRecord(line)
|
||||
if (!record || !Object.hasOwn(record, 'workMode')) return line
|
||||
delete (record as AgentDiagnosticRecord & { workMode?: unknown }).workMode
|
||||
return JSON.stringify(record)
|
||||
}).join('\n')
|
||||
if (cleaned !== original) writePrivateFileAtomic(path, cleaned)
|
||||
}
|
||||
this.#historyCleaned = true
|
||||
}
|
||||
let nextLine = 0
|
||||
while (nextLine < lines.length) {
|
||||
let currentBytes = await privateFileSize(this.currentFilePath)
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('direct Linux stdio Runtime ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('spawns Ask directly without systemd using detached fixed stdio', async () => {
|
||||
it('spawns directly without systemd using detached fixed stdio', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
let launchedIdentity!: LinuxRuntimeProcessIdentity
|
||||
@@ -147,7 +147,6 @@ describe('direct Linux stdio Runtime ownership', () => {
|
||||
args: ['/agent/lib/agent.cjs', 'model-bridge-helper'],
|
||||
cwd: '/workspace',
|
||||
env: { PATH: '/usr/bin:/bin' },
|
||||
workMode: 'execute'
|
||||
},
|
||||
identity: { launchId: 'launch-1', processId: 'process-1' },
|
||||
installationId: 'installation-1',
|
||||
@@ -417,7 +416,6 @@ function profile() {
|
||||
processExecutable: '/bundle/bin/opencode' as const,
|
||||
cwd: '/workspace',
|
||||
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
|
||||
workMode: 'ask' as const,
|
||||
args: ['acp']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
||||
profileInput?: {
|
||||
bundleDirectory: string
|
||||
workspaceDirectory: string
|
||||
workMode: 'ask' | 'execute'
|
||||
modelBridge?: {
|
||||
agentExecutablePath: string
|
||||
bridgeDirectory: string
|
||||
@@ -874,7 +873,6 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
|
||||
args: [...profile.args],
|
||||
cwd: profile.cwd,
|
||||
env: { ...profile.env },
|
||||
workMode: profile.workMode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ async function setup() {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
cleanup.push(() => database.close())
|
||||
const context = { conversationId: randomUUID(), messageId: randomUUID(), requestId: randomUUID(), workMode: 'execute' as 'ask' | 'execute' }
|
||||
const context = { conversationId: randomUUID(), messageId: randomUUID(), requestId: randomUUID() }
|
||||
database.saveLocalConversations([{ header: { id: context.conversationId, title: 'Remote images', updatedAt: 1 },
|
||||
messages: [{ id: context.messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete' }] }])
|
||||
const profile = { id: randomUUID(), name: 'Controlled image provider', modelName: 'controlled-image',
|
||||
@@ -49,8 +49,8 @@ async function setup() {
|
||||
service.initialize()
|
||||
cleanup.push(() => service.dispose())
|
||||
const frames: Uint8Array[] = []
|
||||
const connect = async (mode: 'ask' | 'execute' = 'execute', options: { save?: boolean } = {}) => {
|
||||
const binding = service.bind({ ...context, requestId: randomUUID(), workMode: mode })
|
||||
const connect = async (options: { save?: boolean } = {}) => {
|
||||
const binding = service.bind({ ...context, requestId: randomUUID() })
|
||||
const queue: RuntimeProtocolBinaryFrame[] = []
|
||||
let receiver: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
|
||||
let rejectReceive: ((error: unknown) => void) | undefined
|
||||
@@ -65,7 +65,7 @@ async function setup() {
|
||||
}
|
||||
const wait = new AbortController()
|
||||
const main = new MainImageToolSession(channel, binding, wait.signal)
|
||||
const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', description: await binding.describe() ?? 'Not available', ...(options.save ? { saveDescription: 'Save image' } : {}) }, async payload => {
|
||||
const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', description: await binding.describe(), ...(options.save ? { saveDescription: await binding.describeSave!() } : {}) }, async payload => {
|
||||
if (closed) throw new Error('disconnected')
|
||||
frames.push(payload)
|
||||
const frame = { payload, sequence: '1', consume: async () => {} }
|
||||
@@ -122,12 +122,10 @@ describe('remote image MCP to Main service', () => {
|
||||
expect(h.received).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects Ask calls at the desktop binding and refreshes a new prompt description', async () => {
|
||||
it('refreshes a new prompt description', async () => {
|
||||
const h = await setup()
|
||||
const ask = await h.connect('ask')
|
||||
const denied = await ask.client.callTool({ name: 'generate_image', arguments: { intent: 'create', prompt: 'Blue' } })
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(h.received).toHaveLength(0)
|
||||
const first = await h.connect()
|
||||
expect((await first.client.listTools()).tools[0]!.description).toContain(h.profile.name)
|
||||
h.profile.name = 'Changed next prompt'
|
||||
const execute = await h.connect()
|
||||
expect((await execute.client.listTools()).tools[0]!.description).toContain('Changed next prompt')
|
||||
@@ -137,7 +135,7 @@ describe('remote image MCP to Main service', () => {
|
||||
const h = await setup()
|
||||
const plain = await h.connect()
|
||||
expect((await plain.client.listTools()).tools.map(tool => tool.name)).not.toContain('save_image')
|
||||
const remote = await h.connect('execute', { save: true })
|
||||
const remote = await h.connect({ save: true })
|
||||
expect((await remote.client.listTools()).tools.map(tool => tool.name)).toContain('save_image')
|
||||
// Larger than one 128 KiB chunk so the Agent must reassemble several replies.
|
||||
const large = Buffer.concat([Buffer.from(png, 'base64'), randomBytes(300 * 1024)])
|
||||
@@ -159,15 +157,38 @@ describe('remote image MCP to Main service', () => {
|
||||
expect(JSON.stringify(foreign.content)).toContain('not available in this conversation')
|
||||
})
|
||||
|
||||
it('rejects remote saves in Ask mode at the desktop binding', async () => {
|
||||
it('saves through a mode-free desktop binding', async () => {
|
||||
const h = await setup()
|
||||
const [artifactId] = h.service.persistUploads(h.context, [{ name: 'source.png', mediaType: 'image/png', data: png }])
|
||||
const ask = await h.connect('ask', { save: true })
|
||||
const remote = await h.connect({ save: true })
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-save-'))
|
||||
cleanup.push(() => rm(directory, { recursive: true, force: true }))
|
||||
const denied = await ask.client.callTool({ name: 'save_image', arguments: { artifactId, path: join(directory, 'out.png') } })
|
||||
expect(denied.isError).toBe(true)
|
||||
await expect(readFile(join(directory, 'out.png'))).rejects.toThrow()
|
||||
const saved = await remote.client.callTool({ name: 'save_image', arguments: { artifactId, path: join(directory, 'out.png') } })
|
||||
expect(saved.isError).not.toBe(true)
|
||||
expect((await readFile(join(directory, 'out.png'))).toString('base64')).toBe(png)
|
||||
})
|
||||
|
||||
it('exposes a save-only descriptor for message artifact images with the shared field descriptions', async () => {
|
||||
const h = await setup()
|
||||
h.profile.allowConversationInvocation = false
|
||||
const image = h.database.createImageArtifact({ title: 'Direct reply', mimeType: 'image/png', base64: png })
|
||||
const conversation = h.database.getConversation(h.context.conversationId)
|
||||
conversation.messages[0]!.artifactIds = [image.id]
|
||||
h.database.saveLocalConversations([{ header: { id: h.context.conversationId, title: 'Remote images', updatedAt: 1 }, messages: conversation.messages }])
|
||||
const remote = await h.connect({ save: true })
|
||||
const tools = (await remote.client.listTools()).tools
|
||||
expect(tools.map(tool => tool.name)).toEqual(['save_image'])
|
||||
expect(tools[0]!.description).toContain(image.id)
|
||||
expect(tools[0]!.inputSchema.properties).toMatchObject({
|
||||
artifactId: { description: expect.stringContaining('conversation image/upload references or generate_image results') },
|
||||
path: { description: expect.stringContaining('runtime host') }
|
||||
})
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-save-'))
|
||||
cleanup.push(() => rm(directory, { recursive: true, force: true }))
|
||||
const path = join(directory, 'direct.png')
|
||||
expect((await remote.client.callTool({ name: 'save_image', arguments: { artifactId: image.id, path } })).isError).not.toBe(true)
|
||||
expect(await readFile(path)).toEqual(Buffer.from(png, 'base64'))
|
||||
expect(h.received).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns unknown delivery without sending a second request for the same MCP call', async () => {
|
||||
|
||||
@@ -134,7 +134,7 @@ 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'; imageToolName?: string }>()
|
||||
readonly #sessionRoutes = new Map<string, { operationId: string; socketPath: string; imageToolName?: string }>()
|
||||
readonly #routeToken: string
|
||||
readonly #maximumConnections: number
|
||||
readonly #requestTimeoutMs: number
|
||||
@@ -318,12 +318,12 @@ export class ModelBridgeLoopbackProxy {
|
||||
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, imageToolName: route.imageToolName }))
|
||||
outgoing.end(JSON.stringify({ operationId: route.operationId, imageToolName: route.imageToolName }))
|
||||
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'; imageToolName?: string
|
||||
sessionId?: string; operationId?: string; socketPath?: string; release?: boolean; imageToolName?: string
|
||||
}
|
||||
if (
|
||||
typeof value.sessionId !== 'string' || value.sessionId.length > 128 ||
|
||||
@@ -334,10 +334,9 @@ export class ModelBridgeLoopbackProxy {
|
||||
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')
|
||||
if (value.imageToolName !== undefined && !/^goodbuddy_image_[a-f0-9]{24}$/.test(value.imageToolName)) throw new HttpRequestError(400, 'request-invalid')
|
||||
this.#sessionRoutes.set(value.sessionId, { operationId: value.operationId, socketPath, workMode: value.workMode, imageToolName: value.imageToolName })
|
||||
this.#sessionRoutes.set(value.sessionId, { operationId: value.operationId, socketPath, imageToolName: value.imageToolName })
|
||||
}
|
||||
outgoing.end('{}')
|
||||
return
|
||||
@@ -453,7 +452,6 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
protocol: ModelBridgeProtocol
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
sharedSessions?: boolean
|
||||
opencodeEntrypoint: string
|
||||
environment?: Readonly<NodeJS.ProcessEnv>
|
||||
@@ -479,8 +477,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
loopbackOrigin: origin,
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
workMode: options.sharedSessions ? 'ask' : options.workMode
|
||||
supportsImageInput: options.supportsImageInput
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
@@ -569,7 +566,6 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
loopbackOrigin: string
|
||||
name?: string
|
||||
supportsImageInput?: boolean
|
||||
workMode?: 'ask' | 'execute'
|
||||
}): OpenCodeModelBridgeProviderConfig {
|
||||
const model = boundedMetadataText(input.model, 'Model name')
|
||||
const name = boundedMetadataText(
|
||||
@@ -584,7 +580,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
return {
|
||||
model: openCodeModelBridgeModelId(input.protocol, model),
|
||||
snapshot: false,
|
||||
permission: input.workMode === 'ask' ? 'ask' : 'allow',
|
||||
permission: 'allow',
|
||||
// GoodBuddy owns conversation titles. A first-prompt title request can
|
||||
// race the real ACP prompt, abandon its Provider response, and correctly
|
||||
// poison the no-replay ledger before the user request can run.
|
||||
@@ -593,7 +589,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
disable: true
|
||||
},
|
||||
build: {
|
||||
permission: input.workMode === 'ask' ? 'ask' : 'allow'
|
||||
permission: 'allow'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
|
||||
@@ -58,16 +58,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('model bridge loopback helper', () => {
|
||||
it.each(['ask', 'execute'] as const)(
|
||||
'preserves the %s launch profile offline resources in the final OpenCode spawn',
|
||||
async (workMode) => {
|
||||
it(
|
||||
'preserves launch profile offline resources in the final OpenCode spawn',
|
||||
async () => {
|
||||
const fixture = await createRuntimeBundleTestFixture()
|
||||
temporaryPaths.push(fixture.root)
|
||||
const socketPath = join(fixture.root, 'bridge', 'model.sock')
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workspaceDirectory: fixture.root,
|
||||
workMode,
|
||||
modelBridge: {
|
||||
agentExecutablePath: join(fixture.root, 'agent', 'goodbuddy-agent'),
|
||||
bridgeDirectory: join(fixture.root, 'bridge'),
|
||||
@@ -99,7 +98,6 @@ describe('model bridge loopback helper', () => {
|
||||
protocol: 'anthropic-messages',
|
||||
model: 'private-model',
|
||||
supportsImageInput: false,
|
||||
workMode,
|
||||
opencodeEntrypoint: join(fixture.bundleDirectory, 'bin', 'opencode'),
|
||||
environment: {
|
||||
...profile.env,
|
||||
@@ -138,7 +136,7 @@ describe('model bridge loopback helper', () => {
|
||||
const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.model).toBe('goodbuddy-anthropic/private-model')
|
||||
expect(config.snapshot).toBe(false)
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
|
||||
expect(config.permission).toBe('allow')
|
||||
expect(config.plugin).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
@@ -558,18 +556,17 @@ describe('model bridge loopback helper', () => {
|
||||
loopbackOrigin:
|
||||
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
||||
supportsImageInput: true,
|
||||
workMode: 'ask'
|
||||
})
|
||||
|
||||
expect(config).toMatchObject({
|
||||
model: `${providerId}/private-model`,
|
||||
permission: 'ask',
|
||||
permission: 'allow',
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
},
|
||||
build: {
|
||||
permission: 'ask'
|
||||
permission: 'allow'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
@@ -598,13 +595,12 @@ describe('model bridge loopback helper', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('explicitly allows Execute permissions instead of inheriting directory prompts', () => {
|
||||
it('explicitly allows tool permissions instead of inheriting directory prompts', () => {
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
loopbackOrigin:
|
||||
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
||||
workMode: 'execute'
|
||||
})
|
||||
|
||||
expect(config.permission).toBe('allow')
|
||||
@@ -680,7 +676,7 @@ describe('model bridge Unix broker', () => {
|
||||
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' })
|
||||
method: 'POST', body: JSON.stringify({ sessionId, operationId, socketPath: brokers[index]!.socketPath })
|
||||
})
|
||||
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 },
|
||||
|
||||
@@ -24,15 +24,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('OpenCode direct launch profile', () => {
|
||||
it.each(['ask', 'execute'] as const)('launches Continue through the managed HTTP facade in %s', (workMode) => {
|
||||
it('launches Continue through the managed HTTP facade without a mode option', () => {
|
||||
const fixture = createFixture()
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse({
|
||||
...fixture.manifest, runtimeId: 'continue', provider: 'continue', runtimeVersion: '1.5.47',
|
||||
files: fixture.manifest.files.map(file => file.path === 'bin/opencode' ? { ...file, path: 'lib/continue/dist/cn.js' } : file),
|
||||
entrypoint: { ...fixture.manifest.entrypoint, identity: 'continue-acp', path: 'lib/continue/dist/cn.js', argvPrefix: [] }
|
||||
})
|
||||
expect(() => createOpenCodeLaunchProfile({ ...fixture, manifest, workMode })).toThrow(/model bridge/)
|
||||
const profile = createOpenCodeLaunchProfile({ ...fixture, manifest, workMode,
|
||||
expect(() => createOpenCodeLaunchProfile({ ...fixture, manifest })).toThrow(/model bridge/)
|
||||
const profile = createOpenCodeLaunchProfile({ ...fixture, manifest,
|
||||
modelBridge: {
|
||||
agentExecutablePath: resolve(fixture.bundleDirectory, 'agent', 'goodbuddy-agent'),
|
||||
bridgeDirectory: resolve(fixture.bundleDirectory, 'bridge'),
|
||||
@@ -44,20 +44,21 @@ describe('OpenCode direct launch profile', () => {
|
||||
})
|
||||
expect(profile.args).toEqual(expect.arrayContaining([
|
||||
'model-bridge-helper', '--runtime-id', 'continue', '--continue-entrypoint',
|
||||
resolve(fixture.bundleDirectory, 'lib/continue/dist/cn.js'), '--work-mode', workMode,
|
||||
resolve(fixture.bundleDirectory, 'lib/continue/dist/cn.js'),
|
||||
'--shared-sessions', 'true'
|
||||
]))
|
||||
expect(profile.args).not.toContain('acp')
|
||||
expect(profile.args).not.toContain('--work-mode')
|
||||
expect(profile).not.toHaveProperty('workMode')
|
||||
expect(profile.cwd).toBe(fixture.workspaceDirectory)
|
||||
expect(profile.env.OPENCODE_CONFIG_CONTENT === process.env.OPENCODE_CONFIG_CONTENT).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['ask', 'execute'] as const)(
|
||||
'disables unused automatic Git snapshots for %s',
|
||||
(workMode) => {
|
||||
it(
|
||||
'disables unused automatic Git snapshots',
|
||||
() => {
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...createFixture(),
|
||||
workMode
|
||||
...createFixture()
|
||||
})
|
||||
const config = JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(profile.env).toMatchObject({
|
||||
@@ -66,15 +67,14 @@ describe('OpenCode direct launch profile', () => {
|
||||
OPENCODE_DISABLE_AUTOUPDATE: '1', OPENCODE_DISABLE_MODELS_FETCH: '1'
|
||||
})
|
||||
expect(config.snapshot).toBe(false)
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
|
||||
expect(config.permission).toBe('allow')
|
||||
}
|
||||
)
|
||||
|
||||
it('runs Ask directly in the Workspace', () => {
|
||||
it('runs directly in the Workspace with normal tools available', () => {
|
||||
const fixture = createFixture()
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workMode: 'ask'
|
||||
})
|
||||
|
||||
expect(profile.executable).toBe(
|
||||
@@ -91,20 +91,19 @@ describe('OpenCode direct launch profile', () => {
|
||||
expect(
|
||||
JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
|
||||
).toMatchObject({
|
||||
permission: 'ask',
|
||||
permission: 'allow',
|
||||
agent: {
|
||||
build: {
|
||||
permission: 'ask'
|
||||
permission: 'allow'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('runs Execute directly with the SSH account environment', () => {
|
||||
it('runs directly with the SSH account environment', () => {
|
||||
const fixture = createFixture()
|
||||
const execute = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workMode: 'execute'
|
||||
})
|
||||
|
||||
expect(execute.executable).toBe(
|
||||
@@ -158,7 +157,6 @@ describe('OpenCode direct launch profile', () => {
|
||||
const fixture = createFixture()
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workMode: 'ask'
|
||||
})
|
||||
expect(profile.args.join('\0')).not.toContain('ANTHROPIC_API_KEY')
|
||||
|
||||
@@ -169,14 +167,12 @@ describe('OpenCode direct launch profile', () => {
|
||||
...fixture.manifest,
|
||||
allowedEnvironmentNames: ['HOME']
|
||||
}),
|
||||
workMode: 'ask'
|
||||
})
|
||||
).toThrow(/environment allowlist/iu)
|
||||
expect(() =>
|
||||
createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workspaceDirectory: 'relative/workspace',
|
||||
workMode: 'ask'
|
||||
})
|
||||
).toThrow(/normalized absolute/iu)
|
||||
})
|
||||
@@ -201,9 +197,8 @@ describe('OpenCode direct launch profile', () => {
|
||||
createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
...conflict,
|
||||
workMode: 'ask'
|
||||
})
|
||||
).toMatchObject({ workMode: 'ask' })
|
||||
).toMatchObject({ cwd: conflict.workspaceDirectory })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -231,7 +226,6 @@ describe('OpenCode direct launch profile', () => {
|
||||
)
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workMode: 'ask',
|
||||
modelBridge: {
|
||||
agentExecutablePath,
|
||||
bridgeDirectory,
|
||||
@@ -259,8 +253,6 @@ describe('OpenCode direct launch profile', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||
])
|
||||
@@ -273,7 +265,6 @@ describe('OpenCode direct launch profile', () => {
|
||||
|
||||
const executeProfile = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workMode: 'execute',
|
||||
modelBridge: {
|
||||
agentExecutablePath,
|
||||
bridgeDirectory,
|
||||
@@ -297,7 +288,7 @@ describe('OpenCode direct launch profile', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('allows Execute to use a workspace that contains managed Runtime paths', () => {
|
||||
it('allows a workspace that contains managed Runtime paths', () => {
|
||||
const fixture = createFixture()
|
||||
const workspaceDirectory = resolve(
|
||||
fixture.workspaceDirectory,
|
||||
@@ -342,24 +333,10 @@ describe('OpenCode direct launch profile', () => {
|
||||
manifest: fixture.manifest,
|
||||
bundleDirectory,
|
||||
workspaceDirectory,
|
||||
workMode: 'execute',
|
||||
modelBridge
|
||||
})
|
||||
).toMatchObject({
|
||||
cwd: workspaceDirectory,
|
||||
workMode: 'execute'
|
||||
})
|
||||
expect(
|
||||
createOpenCodeLaunchProfile({
|
||||
manifest: fixture.manifest,
|
||||
bundleDirectory,
|
||||
workspaceDirectory,
|
||||
workMode: 'ask',
|
||||
modelBridge
|
||||
})
|
||||
).toMatchObject({
|
||||
cwd: workspaceDirectory,
|
||||
workMode: 'ask'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,14 +29,12 @@ export type OpenCodeLaunchProfile = {
|
||||
args: readonly string[];
|
||||
cwd: string;
|
||||
env: Readonly<NodeJS.ProcessEnv>;
|
||||
workMode: "ask" | "execute";
|
||||
};
|
||||
|
||||
export function createOpenCodeLaunchProfile(input: {
|
||||
manifest: RemoteRuntimeBundleManifest;
|
||||
bundleDirectory: string;
|
||||
workspaceDirectory: string;
|
||||
workMode: "ask" | "execute";
|
||||
modelBridge?: {
|
||||
agentExecutablePath: string;
|
||||
bridgeDirectory: string;
|
||||
@@ -138,8 +136,6 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
modelBridge.policy.model,
|
||||
"--supports-image-input",
|
||||
modelBridge.policy.supportsImageInput ? "true" : "false",
|
||||
"--work-mode",
|
||||
input.workMode,
|
||||
...(modelBridge.sharedSessions ? ["--shared-sessions", "true"] : []),
|
||||
...(isContinue ? ["--runtime-id", "continue"] : []),
|
||||
isContinue ? "--continue-entrypoint" : "--opencode-entrypoint",
|
||||
@@ -153,7 +149,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
...(isContinue ? {} : { ...OPEN_CODE_RUNTIME_ENVIRONMENT,
|
||||
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode") }),
|
||||
};
|
||||
const permission = input.workMode === "ask" ? "ask" : "allow";
|
||||
const permission = "allow";
|
||||
const environment = {
|
||||
...environmentBase,
|
||||
...(isContinue ? {} : { OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
||||
@@ -168,7 +164,6 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
processExecutable: runtimeCommand.executable,
|
||||
cwd: workspaceDirectory,
|
||||
env: environment,
|
||||
workMode: input.workMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,26 +30,29 @@ describe('OpenCode ACP child event plugin', () => {
|
||||
expect(write).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('hides foreign image tools and removes the current image tool in Ask on a reused Session', async () => {
|
||||
let mode = 'execute'
|
||||
const imageToolName = `goodbuddy_image_${'a'.repeat(24)}`
|
||||
it('hides foreign image tools and refreshes the current binding on a reused Session without native tool whitelists', async () => {
|
||||
let imageToolName: string | undefined
|
||||
const patch = vi.fn()
|
||||
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: async () => ({ ok: true, json: async () => ({ operationId: mode, workMode: mode, imageToolName }) }) }
|
||||
fetch: async () => ({ ok: true, json: async () => ({ operationId: 'operation', imageToolName }) }) }
|
||||
)
|
||||
const hooks = await factory({ client: {
|
||||
_client: { get: vi.fn(), post: vi.fn(), patch },
|
||||
session: { get: async () => ({ data: { id: 'root' } }) }
|
||||
} })
|
||||
try {
|
||||
for (mode of ['execute', 'ask', 'execute']) {
|
||||
for (imageToolName of [`goodbuddy_image_${'a'.repeat(24)}`, undefined, `goodbuddy_image_${'b'.repeat(24)}`]) {
|
||||
await hooks.event({ event: { type: 'session.status', properties: { sessionID: 'root', status: { type: 'idle' } } } })
|
||||
await hooks['chat.message']({ sessionID: 'root' }, { message: { id: mode } })
|
||||
await hooks['chat.message']({ sessionID: 'root' }, { message: { id: 'message' } })
|
||||
const rules = patch.mock.calls.at(-1)![0].body.permission
|
||||
expect(rules).toContainEqual({ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' })
|
||||
expect(rules.some((rule: { permission: string; action: string }) => rule.permission === `${imageToolName}_*` && rule.action === 'allow')).toBe(mode === 'execute')
|
||||
expect(rules).toEqual([
|
||||
{ permission: '*', pattern: '*', action: 'allow' },
|
||||
{ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' },
|
||||
...(imageToolName ? [{ permission: `${imageToolName}_*`, pattern: '*', action: 'allow' }] : [])
|
||||
])
|
||||
}
|
||||
} finally { await hooks.dispose() }
|
||||
})
|
||||
@@ -57,7 +60,7 @@ describe('OpenCode ACP child event plugin', () => {
|
||||
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' }) }
|
||||
return { ok: operations.has(session), json: async () => ({ operationId: operations.get(session) }) }
|
||||
})
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource('http://127.0.0.1:12345/fixture').replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
|
||||
|
||||
@@ -26,7 +26,7 @@ async function plugin(input?: {
|
||||
}) {
|
||||
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'; imageToolName?: string }>()
|
||||
const modelMessages = new Map<string, { sessionId: string; operationId: string; imageToolName?: string }>()
|
||||
const modelRoute = async (sessionId: string, messageId: string) => {
|
||||
const key = `${sessionId}\0${messageId}`
|
||||
const previous = modelMessages.get(key)
|
||||
@@ -43,8 +43,8 @@ async function plugin(input?: {
|
||||
}
|
||||
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, imageToolName } = await response.json() as { operationId: string; workMode: 'ask' | 'execute'; imageToolName?: string }
|
||||
const route = { sessionId: root, operationId, workMode, imageToolName }
|
||||
const { operationId, imageToolName } = await response.json() as { operationId: string; imageToolName?: string }
|
||||
const route = { sessionId: root, operationId, imageToolName }
|
||||
modelMessages.set(key, route)
|
||||
return route
|
||||
}
|
||||
@@ -116,15 +116,8 @@ async function plugin(input?: {
|
||||
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' }))
|
||||
]
|
||||
// Native Session rules also authorize tools in child Sessions.
|
||||
const 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.
|
||||
@@ -133,9 +126,6 @@ async function plugin(input?: {
|
||||
}
|
||||
permission.push({ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' })
|
||||
if (route.imageToolName) {
|
||||
permission.push({ permission: `${route.imageToolName}_story_graph_*`, pattern: '*', action: 'allow' })
|
||||
}
|
||||
if (route.workMode === 'execute' && route.imageToolName) {
|
||||
permission.push({ permission: `${route.imageToolName}_*`, pattern: '*', action: 'allow' })
|
||||
}
|
||||
await input!.client._client.patch({
|
||||
|
||||
@@ -576,7 +576,7 @@ describe('AgentProtocolServer connection bounds', () => {
|
||||
expect(daemonCapabilitiesSchema.parse(result)).toMatchObject({
|
||||
capabilities: [
|
||||
{ name: 'agent/control', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 5, critical: true },
|
||||
{ name: 'runtime/acp', version: 6, critical: true },
|
||||
{
|
||||
name: 'runtime/model-bridge',
|
||||
version: 1,
|
||||
|
||||
@@ -58,7 +58,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c
|
||||
const address = model.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No fixture port')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({ protocol: 'openai-chat-completions',
|
||||
model: 'fixture', loopbackOrigin: `http://127.0.0.1:${address.port}/${'a'.repeat(43)}`, workMode: 'execute' })
|
||||
model: 'fixture', loopbackOrigin: `http://127.0.0.1:${address.port}/${'a'.repeat(43)}` })
|
||||
const child = spawn(binary, ['acp'], { cwd: root, stdio: 'pipe', env: {
|
||||
...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, plugin: [pathToFileURL(pluginPath).href] }),
|
||||
OPENCODE_CONFIG_DIR: join(process.cwd(), '.runtime-resources', 'opencode-config'),
|
||||
@@ -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.' }] }, 'execute')
|
||||
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] })
|
||||
let questionId: string | undefined
|
||||
await expect.poll(async () => {
|
||||
const page = transcript.page({ bindingId: 'binding', operationId: 'operation',
|
||||
|
||||
@@ -342,7 +342,6 @@ async function ownedHarness(shared: boolean) {
|
||||
}
|
||||
|
||||
function harness(input: {
|
||||
workMode?: 'ask' | 'execute'
|
||||
now?: number
|
||||
maximumOutputBytes?: number
|
||||
bridgeCloseError?: boolean
|
||||
@@ -359,14 +358,12 @@ function harness(input: {
|
||||
}
|
||||
launchError?: Error
|
||||
} = {}) {
|
||||
const workMode = input.workMode ?? 'ask'
|
||||
let now = input.now ?? 1_000
|
||||
const journal = new MemoryAcpJournal()
|
||||
const process = new FakeProcess()
|
||||
input.configureProcess?.(process)
|
||||
const outputFrames: string[] = []
|
||||
const launches: Array<{
|
||||
workMode: 'ask' | 'execute'
|
||||
scratch: string
|
||||
}> = []
|
||||
const lifecycle: string[] = []
|
||||
@@ -411,7 +408,6 @@ function harness(input: {
|
||||
}
|
||||
launchModelBridges.push(launch.modelBridge)
|
||||
launches.push({
|
||||
workMode: launch.workMode,
|
||||
scratch: launch.scratch
|
||||
})
|
||||
if (input.uniqueProcesses && launches.length > 1) {
|
||||
@@ -480,7 +476,6 @@ function harness(input: {
|
||||
bindingId: openRequest.bindingId,
|
||||
operationId: 'request-1',
|
||||
requestId: 'request-1',
|
||||
workMode,
|
||||
controllerId: context.controller.controllerId,
|
||||
controllerGeneration: context.controller.generation,
|
||||
connectionGeneration: context.controller.generation,
|
||||
@@ -592,7 +587,7 @@ describe('RuntimeAcpBackend', () => {
|
||||
})
|
||||
|
||||
it('binds MCP image calls to authenticated blob framing and closes each prompt endpoint', async () => {
|
||||
const fixture = harness({ workMode: 'execute' })
|
||||
const fixture = harness()
|
||||
const client = new Client({ name: 'image-backend-test', version: '1' })
|
||||
try {
|
||||
await open(fixture)
|
||||
@@ -623,19 +618,19 @@ describe('RuntimeAcpBackend', () => {
|
||||
} finally { await client.close(); await fixture.backend.dispose() }
|
||||
})
|
||||
|
||||
it('rejects image tool preparation in Ask before launching a process or bridge', async () => {
|
||||
it('accepts image tool preparation without a mode', async () => {
|
||||
const fixture = harness()
|
||||
try {
|
||||
await open(fixture)
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
imageTool: { channelId: 'image-channel', channelEpoch: '1', description: 'Images' }
|
||||
}))).rejects.toThrow('Execute')
|
||||
expect(fixture.launches).toHaveLength(0)
|
||||
}))).resolves.toHaveProperty('imageToolUrl')
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
expect(fixture.lifecycle).not.toContain('bridge-listen')
|
||||
} finally { await fixture.backend.dispose() }
|
||||
})
|
||||
|
||||
it('binds Story Graph Ask discovery and reads to authenticated blob frames', async () => {
|
||||
it('binds Story Graph discovery and reads to authenticated blob frames', async () => {
|
||||
const fixture = harness()
|
||||
const client = new Client({ name: 'graph-backend-test', version: '1' })
|
||||
try {
|
||||
@@ -664,8 +659,8 @@ describe('RuntimeAcpBackend', () => {
|
||||
await expect(fetch(accepted.imageToolUrl)).rejects.toThrow()
|
||||
} finally { await client.close(); await fixture.backend.dispose() }
|
||||
})
|
||||
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' })
|
||||
it.each([false, true])('reuses a Session for mode-free prompts (shared=%s)', async (shared) => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: shared })
|
||||
try {
|
||||
await open(fixture)
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation())
|
||||
@@ -673,19 +668,15 @@ describe('RuntimeAcpBackend', () => {
|
||||
bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1'
|
||||
})
|
||||
const next = fixture.preparation({
|
||||
operationId: 'request-2', requestId: 'request-2', promptSequence: 1, workMode: 'ask'
|
||||
operationId: 'request-2', requestId: 'request-2', promptSequence: 1
|
||||
})
|
||||
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')
|
||||
}
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', next)).resolves.not.toHaveProperty('workMode')
|
||||
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
|
||||
}))).resolves.not.toHaveProperty('workMode')
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
@@ -793,7 +784,6 @@ describe('RuntimeAcpBackend', () => {
|
||||
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}`
|
||||
}))
|
||||
}
|
||||
@@ -985,11 +975,10 @@ describe('RuntimeAcpBackend', () => {
|
||||
expect(acceptance).toMatchObject({
|
||||
bindingId: 'binding-1',
|
||||
operationId: 'request-1',
|
||||
workMode: 'ask',
|
||||
requestId: 'request-1'
|
||||
})
|
||||
expect(fixture.launches).toEqual([
|
||||
{ workMode: 'ask', scratch: '/scratch' }
|
||||
{ scratch: '/scratch' }
|
||||
])
|
||||
|
||||
fixture.journal.appendAcpFrame({
|
||||
@@ -1058,10 +1047,10 @@ describe('RuntimeAcpBackend', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['ask', 'execute'] as const)(
|
||||
'accepts %s as the complete prompt authorization',
|
||||
async (workMode) => {
|
||||
const fixture = harness({ workMode })
|
||||
it(
|
||||
'accepts a mode-free prompt',
|
||||
async () => {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
await expect(
|
||||
invoke(
|
||||
@@ -1069,7 +1058,7 @@ describe('RuntimeAcpBackend', () => {
|
||||
'runtime/preparePrompt',
|
||||
fixture.preparation()
|
||||
)
|
||||
).resolves.toMatchObject({ workMode })
|
||||
).resolves.not.toHaveProperty('workMode')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1313,7 +1302,6 @@ describe('RuntimeAcpBackend', () => {
|
||||
'runtime.exited',
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
workMode: 'ask',
|
||||
outcome: 'failed'
|
||||
}
|
||||
)
|
||||
@@ -1322,14 +1310,12 @@ describe('RuntimeAcpBackend', () => {
|
||||
'runtime.starting',
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
workMode: 'ask'
|
||||
}
|
||||
)
|
||||
expect(fixture.diagnostics.tryRecord).toHaveBeenCalledWith(
|
||||
'runtime.started',
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
workMode: 'ask'
|
||||
}
|
||||
)
|
||||
await fixture.backend.dispose()
|
||||
@@ -1354,7 +1340,6 @@ describe('RuntimeAcpBackend', () => {
|
||||
'runtime.start.failed',
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
workMode: 'ask',
|
||||
error: launchError
|
||||
}
|
||||
)
|
||||
@@ -1934,7 +1919,6 @@ describe('RuntimeAcpBackend', () => {
|
||||
channelEpoch: channel.channelEpoch,
|
||||
operationId: `operation-${index}`,
|
||||
requestId: `operation-${index}`,
|
||||
workMode: index % 2 ? 'execute' : 'ask'
|
||||
}))
|
||||
expect(journal.getAcpCursor(
|
||||
bindingId, channel.channelEpoch, 'runtime-to-main'
|
||||
|
||||
@@ -229,7 +229,6 @@ export type RuntimeAcpProcessLaunch = {
|
||||
bundle: RuntimeAcpVerifiedBundle
|
||||
workspace: RuntimeAcpWorkspace
|
||||
scratch: string
|
||||
workMode: 'ask' | 'execute'
|
||||
deadlineAt: string
|
||||
budget: RemotePromptOperationPreparation['budget']
|
||||
sharedSessions?: boolean
|
||||
@@ -321,7 +320,6 @@ type BindingState = {
|
||||
transportState: 'live' | 'detached' | 'replaying'
|
||||
resolvedBundle: RuntimeAcpResolvedBundle
|
||||
state: 'open' | 'running' | 'stopping' | 'closed' | 'outcome-unknown'
|
||||
workMode?: 'ask' | 'execute'
|
||||
process?: RuntimeAcpProcessOwner
|
||||
sharedProcess?: SharedProcess
|
||||
unsubscribeOutput?: () => void
|
||||
@@ -1071,17 +1069,6 @@ export class RuntimeAcpBackend {
|
||||
'capacity'
|
||||
)
|
||||
}
|
||||
if (
|
||||
binding.process !== undefined &&
|
||||
binding.workMode !== undefined &&
|
||||
binding.workMode !== preparation.workMode &&
|
||||
binding.sharedProcess === undefined
|
||||
) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Runtime process work mode cannot change',
|
||||
'conflict'
|
||||
)
|
||||
}
|
||||
|
||||
let verified: RuntimeAcpVerifiedBundle
|
||||
try {
|
||||
@@ -1173,8 +1160,8 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preparation.imageTool && (((preparation.imageTool.description || preparation.imageTool.saveDescription) && preparation.workMode !== 'execute') || !this.#options.blobSink)) {
|
||||
throw new RuntimeAcpBackendError('Image tools require Execute and a desktop transport', 'identity')
|
||||
if (preparation.imageTool && !this.#options.blobSink) {
|
||||
throw new RuntimeAcpBackendError('Image tools require a desktop transport', 'identity')
|
||||
}
|
||||
if (requestedModelBridgePolicy !== undefined) {
|
||||
await this.#startModelBridge(binding, preparation, workspace)
|
||||
@@ -1207,7 +1194,6 @@ export class RuntimeAcpBackend {
|
||||
bindingId: preparation.bindingId,
|
||||
operationId: preparation.operationId,
|
||||
requestId: preparation.requestId,
|
||||
workMode: preparation.workMode,
|
||||
deadlineAt: preparation.deadlineAt,
|
||||
acceptedAt
|
||||
})
|
||||
@@ -1225,8 +1211,7 @@ export class RuntimeAcpBackend {
|
||||
])
|
||||
: undefined
|
||||
this.#options.diagnostics?.tryRecord('runtime.starting', {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode
|
||||
runtimeId: verified.manifest.runtimeId
|
||||
})
|
||||
try {
|
||||
const launch: RuntimeAcpProcessLaunch = {
|
||||
@@ -1234,7 +1219,6 @@ export class RuntimeAcpBackend {
|
||||
bundle: verified,
|
||||
workspace,
|
||||
scratch: workspace.scratchDirectory,
|
||||
workMode: preparation.workMode,
|
||||
deadlineAt: preparation.deadlineAt,
|
||||
budget: preparation.budget,
|
||||
...(sharedKey === undefined ? {} : { sharedSessions: true }),
|
||||
@@ -1260,7 +1244,6 @@ export class RuntimeAcpBackend {
|
||||
} catch (error) {
|
||||
this.#options.diagnostics?.tryRecord('runtime.start.failed', {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode,
|
||||
error
|
||||
})
|
||||
await this.#closeModelBridge(binding, false).catch(
|
||||
@@ -1295,7 +1278,6 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
binding.process = process
|
||||
binding.workspaceDirectory = workspace.workspaceDirectory
|
||||
binding.workMode = preparation.workMode
|
||||
binding.state = 'running'
|
||||
binding.operations.set(preparation.operationId, {
|
||||
preparationDigest,
|
||||
@@ -1336,8 +1318,7 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
this.#options.diagnostics?.tryRecord('runtime.started', {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode
|
||||
runtimeId: verified.manifest.runtimeId
|
||||
})
|
||||
} else if (!binding.sharedProcess) {
|
||||
try {
|
||||
@@ -1355,7 +1336,6 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.workMode = preparation.workMode
|
||||
binding.inputBytes = 0
|
||||
if (!binding.operations.has(preparation.operationId)) {
|
||||
binding.operations.set(preparation.operationId, {
|
||||
@@ -1521,9 +1501,9 @@ export class RuntimeAcpBackend {
|
||||
process: binding.process,
|
||||
...(binding.sharedProcess ? {
|
||||
transport: binding.sharedProcess.transport,
|
||||
prepareSession: async (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => {
|
||||
prepareSession: async (sessionId: string, operationId: string) => {
|
||||
await binding.sharedProcess!.transport.setModelRoute(
|
||||
sessionId, operationId, binding.modelBridgeBroker!.socketPath, workMode,
|
||||
sessionId, operationId, binding.modelBridgeBroker!.socketPath,
|
||||
binding.imageTool ? remoteImageToolMcpName(binding.request.bindingId) : undefined
|
||||
)
|
||||
}
|
||||
@@ -1543,7 +1523,7 @@ export class RuntimeAcpBackend {
|
||||
binding.poisoned ? 'outcome-unknown' : status
|
||||
})
|
||||
}
|
||||
const result = await binding.ownedAcp.start(request, prepared.acceptance.workMode)
|
||||
const result = await binding.ownedAcp.start(request)
|
||||
prepared.ownedPromptStarted = true
|
||||
if (binding.ownedPromptStartTimer !== undefined) {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
@@ -2395,9 +2375,6 @@ export class RuntimeAcpBackend {
|
||||
diagnosticOutcome = 'outcome-unknown'
|
||||
this.#options.diagnostics?.tryRecord('runtime.exited', {
|
||||
runtimeId: binding.resolvedBundle.entry.runtimeId,
|
||||
...(binding.workMode === undefined
|
||||
? {}
|
||||
: { workMode: binding.workMode }),
|
||||
outcome: diagnosticOutcome,
|
||||
error
|
||||
})
|
||||
@@ -2406,9 +2383,6 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
this.#options.diagnostics?.tryRecord('runtime.exited', {
|
||||
runtimeId: binding.resolvedBundle.entry.runtimeId,
|
||||
...(binding.workMode === undefined
|
||||
? {}
|
||||
: { workMode: binding.workMode }),
|
||||
outcome: diagnosticOutcome
|
||||
})
|
||||
this.#finishBinding(binding, state)
|
||||
|
||||
@@ -37,7 +37,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('production Runtime composition', () => {
|
||||
it('loads Runtime metadata independently of Ask-only Host prerequisites', async () => {
|
||||
it('loads Runtime metadata without obsolete confinement prerequisites', async () => {
|
||||
const fixture = await createRuntimeBundleTestFixture()
|
||||
const root = fixture.root
|
||||
temporaryPaths.push(root)
|
||||
|
||||
@@ -275,7 +275,6 @@ export async function createProductionRuntimeProtocol(
|
||||
profileInput: {
|
||||
bundleDirectory: launch.bundle.bundleDirectory,
|
||||
workspaceDirectory: launch.workspace.workspaceDirectory,
|
||||
workMode: launch.workMode,
|
||||
...(launch.modelBridge === undefined ||
|
||||
options.agentExecutablePath === undefined
|
||||
? {}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { canonicalJson } from '../shared/agent-protocol/canonical'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -30,6 +32,33 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('SemanticPromptStore', () => {
|
||||
it('preserves legacy preparation digests and replay identity instead of accepting a mode-free resubmission', () => {
|
||||
const first = store()
|
||||
const identity = { bindingId: 'legacy-binding', operationId: 'legacy-operation',
|
||||
requestId: 'legacy-operation', controllerId: 'controller-1', promptSequence: 0 }
|
||||
const digest = (value: unknown) => `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`
|
||||
const preparation = { ...identity, preparationDigest: digest({ ...identity, workMode: 'ask' }) }
|
||||
const start = { bindingId: identity.bindingId, operationId: identity.operationId,
|
||||
startDigest: digest({ prompt: 'original' }), sessionId: 'legacy-session' }
|
||||
first.prepare(preparation)
|
||||
first.begin(start)
|
||||
const payload = { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'original' } } }
|
||||
first.append({ ...identity, kind: 'session-update', payload })
|
||||
first.close()
|
||||
const reopened = new SemanticPromptStore(join(temporary.at(-1)!, 'state', 'prompts.sqlite'))
|
||||
try {
|
||||
expect(reopened.prepare(preparation)).toEqual({ created: false })
|
||||
expect(() => reopened.prepare({ ...preparation, preparationDigest: digest(identity) })).toThrow(/different preparation/)
|
||||
expect(reopened.findStarted({ ...identity, startDigest: start.startDigest })).toMatchObject({
|
||||
state: 'outcome-unknown', sessionId: 'legacy-session'
|
||||
})
|
||||
const page = reopened.page({ ...identity, afterSequence: '0', limit: 10 })
|
||||
expect(page.events[0]).toMatchObject({ sequence: '1', payload })
|
||||
expect(page.acknowledgedSequence).toBe('0')
|
||||
expect(reopened.acknowledge({ ...identity, acknowledgedSequence: page.latestSequence }))
|
||||
.toMatchObject({ acknowledgedSequence: page.latestSequence })
|
||||
} finally { reopened.close() }
|
||||
})
|
||||
it('replays unacknowledged output and prunes it after durable acknowledgement', () => {
|
||||
const first = store()
|
||||
first.prepare({
|
||||
|
||||
@@ -97,11 +97,11 @@ it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and
|
||||
expect((await connection.listTools()).tools).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and runtime assignment gate', async workMode => {
|
||||
it('direct Model uses the same reader and runtime assignment gate', async () => {
|
||||
const f = fixture()
|
||||
const provider = new ModelToolProvider(process.cwd(), [], undefined, f.gateway)
|
||||
cleanups.push(() => provider.dispose())
|
||||
const context = { conversationId: 'graph', workMode, knowledgeCapabilityToken: f.token }
|
||||
const context = { conversationId: 'graph', knowledgeCapabilityToken: f.token }
|
||||
expect((await provider.listTools(context, f.wait.signal)).filter(tool => tool.name.startsWith('story_graph_')).map(tool => tool.name)).toEqual(storyGraphToolNames)
|
||||
const result = await provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)
|
||||
expect(JSON.stringify(result)).toContain(f.projectId)
|
||||
|
||||
@@ -701,6 +701,7 @@ function entryFromMetadata(
|
||||
kind,
|
||||
...(metadata.isFile() ? { byteLength: metadata.size } : {}),
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
...(metadata.birthtimeMs > 0 ? { createdAt: metadata.birthtime.toISOString() } : {}),
|
||||
executable: metadata.isFile() && (metadata.mode & 0o111) !== 0
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user