feat: show runtime checklists and support remote Continue
Display native OpenCode and Continue checklists alongside scheduled tasks, preserving request ownership, explicit clears and saved progress. Bundle both remote runtimes and support managed Continue installation, model routing, cancellation and restart continuation. Validation: real local and Linux remote model checks, full Electron remote Continue flows, typecheck and lint passed. Full suite: 4707 passed, 67 skipped, one OpenCode compaction failure passed targeted rerun. Release note: Display native execution checklists above conversations and support managed remote Continue projects with saved progress and cancellation recovery.
This commit is contained in:
+20
-8
@@ -45,6 +45,7 @@ import {
|
||||
type VerifiedRuntimeBundle
|
||||
} from './runtime-bundle-verifier'
|
||||
import { createProductionRuntimeProtocol } from './runtime-composition'
|
||||
import { runContinueAcpHelper } from './continue-acp-helper'
|
||||
import {
|
||||
runOpenCodeModelBridgeHelper,
|
||||
type ModelBridgeProtocol
|
||||
@@ -86,7 +87,7 @@ export type AgentCliDependencies = {
|
||||
loadRegisteredInstallation?: typeof loadRegisteredAgentBundle
|
||||
runtimePaths?: (
|
||||
installationId: string,
|
||||
runtimeId: 'opencode',
|
||||
runtimeId: 'opencode' | 'continue',
|
||||
bundleDigest: string
|
||||
) => ManagedRuntimePaths
|
||||
runtimeReleaseKeyRegistry?: AgentReleaseKeyRegistry
|
||||
@@ -187,6 +188,8 @@ async function runModelBridgeHelper(
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'shared-sessions',
|
||||
'runtime-id',
|
||||
'continue-entrypoint',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
requireOptions(options, [
|
||||
@@ -194,8 +197,7 @@ async function runModelBridgeHelper(
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'opencode-entrypoint'
|
||||
'work-mode'
|
||||
])
|
||||
const protocol = options.protocol
|
||||
if (
|
||||
@@ -216,6 +218,16 @@ async function runModelBridgeHelper(
|
||||
if (options['shared-sessions'] !== undefined && options['shared-sessions'] !== 'true') {
|
||||
throw new Error('Invalid model bridge shared-sessions option')
|
||||
}
|
||||
if (options['runtime-id'] === 'continue') {
|
||||
requireOptions(options, ['continue-entrypoint'])
|
||||
return runContinueAcpHelper({
|
||||
socketPath: options['socket-path']!, protocol, model: options.model!,
|
||||
supportsImageInput: imageInput === 'true', workMode,
|
||||
sharedSessions: options['shared-sessions'] === 'true', entrypoint: options['continue-entrypoint']!
|
||||
})
|
||||
}
|
||||
if (options['runtime-id'] !== undefined && options['runtime-id'] !== 'opencode') throw new Error('Invalid Runtime helper identity')
|
||||
requireOptions(options, ['opencode-entrypoint'])
|
||||
return await (
|
||||
dependencies.runModelBridgeHelper ??
|
||||
runOpenCodeModelBridgeHelper
|
||||
@@ -943,7 +955,7 @@ function resolveInstallationPaths(
|
||||
|
||||
function resolveRuntimePaths(
|
||||
installationId: string,
|
||||
runtimeId: 'opencode',
|
||||
runtimeId: 'opencode' | 'continue',
|
||||
bundleDigest: string,
|
||||
dependencies: AgentCliDependencies
|
||||
): ManagedRuntimePaths {
|
||||
@@ -997,8 +1009,8 @@ function validateInstallationId(value: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
function validateRuntimeId(value: string): 'opencode' {
|
||||
if (value !== 'opencode') {
|
||||
function validateRuntimeId(value: string): 'opencode' | 'continue' {
|
||||
if (value !== 'opencode' && value !== 'continue') {
|
||||
throw new Error('Invalid Runtime ID')
|
||||
}
|
||||
return value
|
||||
@@ -1022,13 +1034,13 @@ function validateRuntimeArchitecture(
|
||||
|
||||
function assertActivatedRuntimeIdentity(
|
||||
verified: VerifiedRuntimeBundle,
|
||||
runtimeId: 'opencode',
|
||||
runtimeId: 'opencode' | 'continue',
|
||||
bundleDigest: string,
|
||||
architecture: AgentArchitecture
|
||||
): void {
|
||||
if (
|
||||
verified.manifest.runtimeId !== runtimeId ||
|
||||
verified.manifest.provider !== 'opencode' ||
|
||||
verified.manifest.provider !== runtimeId ||
|
||||
verified.manifest.bundleDigest !== bundleDigest ||
|
||||
verified.manifest.architecture !== architecture
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Agent, type McpServer } from '@agentclientprotocol/sdk'
|
||||
import { ContinueHostAdapter } from '../main/agent/continue-host-adapter'
|
||||
import { promptWithUntrustedConversationHistory } 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'
|
||||
|
||||
export async function runContinueAcpHelper(options: {
|
||||
socketPath: string
|
||||
protocol: ModelBridgeProtocol
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
sharedSessions?: boolean
|
||||
entrypoint: string
|
||||
}): Promise<number> {
|
||||
const proxy = new ModelBridgeLoopbackProxy({
|
||||
exchange: createUnixModelBridgeExchange({ socketPath: options.socketPath }),
|
||||
sharedSessions: options.sharedSessions
|
||||
})
|
||||
const origin = await proxy.listen()
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-continue-'))
|
||||
const questions = new Map<string, { sessionId: string; adapter: ContinueHostAdapter }>()
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const replies = createServer((request, response) => { void (async () => {
|
||||
if (request.method !== 'POST' || request.url !== `/${token}`) { response.writeHead(404).end(); return }
|
||||
let body = ''
|
||||
for await (const chunk of request) { body += chunk; if (body.length > 64 * 1024) throw new Error('Question reply too large') }
|
||||
const value = JSON.parse(body) as { questionId: string; sessionId: string; answers: string[][] }
|
||||
const pending = questions.get(value.questionId)
|
||||
if (!pending || pending.sessionId !== value.sessionId) { response.writeHead(409).end(); return }
|
||||
await pending.adapter.respondToQuestion(value.questionId, value.answers.length ? value.answers : undefined)
|
||||
questions.delete(value.questionId)
|
||||
response.writeHead(200).end('{}')
|
||||
})().catch(() => response.writeHead(400).end()) })
|
||||
await new Promise<void>((resolve, reject) => { replies.once('error', reject); replies.listen(0, '127.0.0.1', resolve) })
|
||||
const questionEndpoint = `http://127.0.0.1:${(replies.address() as import('node:net').AddressInfo).port}/${token}`
|
||||
const sessions = new Map<string, {
|
||||
cwd: string
|
||||
mcpServers: McpServer[]
|
||||
history: NonNullable<AgentExecutionRequest['history']>
|
||||
active?: { abort: AbortController; adapter: ContinueHostAdapter; finished: Promise<void> }
|
||||
}>()
|
||||
const modelId = openCodeModelBridgeModelId(options.protocol, options.model)
|
||||
const configOptions = [{ id: 'model', name: 'Model', type: 'select' as const, currentValue: modelId,
|
||||
options: [{ value: modelId, name: options.model }] }]
|
||||
const agent: Agent = {
|
||||
initialize: async () => ({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {
|
||||
loadSession: true, mcpCapabilities: { http: true }, promptCapabilities: { image: true },
|
||||
sessionCapabilities: { resume: {}, close: {} }
|
||||
}, agentInfo: { name: 'GoodBuddy Continue', version: '1.5.47' } }),
|
||||
authenticate: async () => ({}),
|
||||
newSession: async ({ cwd, mcpServers }) => {
|
||||
const sessionId = randomUUID()
|
||||
sessions.set(sessionId, { cwd, mcpServers, history: [] })
|
||||
return { sessionId, configOptions }
|
||||
},
|
||||
loadSession: async ({ sessionId, cwd, mcpServers }) => {
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session || session.cwd !== cwd) throw new Error('Continue Session is unavailable')
|
||||
session.mcpServers = mcpServers
|
||||
return { configOptions }
|
||||
},
|
||||
resumeSession: async ({ sessionId, cwd, mcpServers }) => {
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session || session.cwd !== cwd) throw new Error('Continue Session is unavailable')
|
||||
if (mcpServers) session.mcpServers = mcpServers
|
||||
return { configOptions }
|
||||
},
|
||||
setSessionConfigOption: async ({ sessionId, configId, value }) => {
|
||||
if (!sessions.has(sessionId) || configId !== 'model' || value !== modelId) throw new Error('Continue model selection does not match the prepared model')
|
||||
return { configOptions }
|
||||
},
|
||||
prompt: async ({ sessionId, prompt }) => {
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session || session.active) throw new Error('Continue Session is unavailable or busy')
|
||||
const route = options.sharedSessions ? await fetch(`${origin}/session?sessionId=${encodeURIComponent(sessionId)}`, {
|
||||
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' }
|
||||
}) : undefined
|
||||
const workMode = route?.workMode ?? options.workMode
|
||||
const configPath = join(root, `${sessionId}.json`)
|
||||
await writeFile(configPath, JSON.stringify({ name: 'GoodBuddy', version: '1.0.0', schema: 'v1',
|
||||
mcpServers: workMode === 'execute' ? session.mcpServers.map(server => {
|
||||
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
|
||||
return { name: server.name, type: 'streamable-http', url: server.url,
|
||||
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
|
||||
}) : [] }), { mode: 0o600 })
|
||||
const adapter = new ContinueHostAdapter({
|
||||
binaryPath: options.entrypoint, configPath, workspace: session.cwd, cacheRoot: root, mode: 'chat',
|
||||
modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model,
|
||||
protocol: options.protocol, authentication: 'api-key', apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
||||
baseUrl: `${origin}/v1`, supportsImageInput: options.supportsImageInput,
|
||||
...(route ? { requestHeaders: { 'x-goodbuddy-session': sessionId, 'x-goodbuddy-operation': route.operationId } } : {}) },
|
||||
launchHost: (entry, args, launch) => spawn(process.execPath, [entry, ...args], { ...launch, stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
})
|
||||
const abort = new AbortController()
|
||||
let finish!: () => void
|
||||
const text = prompt.filter(part => part.type === 'text').map(part => part.text).join('\n')
|
||||
const images = prompt.filter(part => part.type === 'image').map<AgentImage>((part, index) => {
|
||||
if (part.mimeType !== 'image/png' && part.mimeType !== 'image/jpeg') throw new Error('Unsupported Continue image type')
|
||||
return { name: `image-${index}`, mediaType: part.mimeType, data: part.data }
|
||||
})
|
||||
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 () => workMode === 'execute' ? 'once' : 'deny', {
|
||||
workMode, images,
|
||||
onEvent: async event => {
|
||||
if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: {
|
||||
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: event.delta }
|
||||
} })
|
||||
if (event.type === 'checklist') await connection.sessionUpdate({ sessionId, update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-checklist',
|
||||
_meta: { goodbuddyChecklist: event.checklist }
|
||||
} })
|
||||
if (event.type === 'tool') await connection.sessionUpdate({ sessionId, update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: event.tool.callId, title: event.tool.name,
|
||||
status: event.tool.state === 'running' ? 'in_progress' : event.tool.state,
|
||||
rawInput: event.tool.input,
|
||||
content: event.tool.output || event.tool.error ? [{ type: 'content', content: {
|
||||
type: 'text', text: event.tool.error ?? event.tool.output!
|
||||
} }] : []
|
||||
} })
|
||||
if (event.type === 'question') {
|
||||
questions.set(event.questionId, { sessionId, adapter })
|
||||
await connection.sessionUpdate({ sessionId, update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: event.questionId,
|
||||
_meta: { goodbuddyQuestion: { endpoint: questionEndpoint,
|
||||
question: { id: event.questionId, sessionID: sessionId, questions: event.questions } } }
|
||||
} })
|
||||
}
|
||||
}
|
||||
})
|
||||
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: text }, { role: 'assistant', content: result.text })
|
||||
return { stopReason: 'end_turn', ...(result.usage ? { usage: {
|
||||
inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens,
|
||||
totalTokens: result.usage.inputTokens + result.usage.outputTokens,
|
||||
cachedReadTokens: result.usage.cacheReadTokens, cachedWriteTokens: result.usage.cacheWriteTokens
|
||||
} } : {}) }
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) return { stopReason: 'cancelled' }
|
||||
throw error
|
||||
} finally {
|
||||
await adapter.dispose()
|
||||
for (const [id, pending] of questions) if (pending.adapter === adapter) questions.delete(id)
|
||||
await rm(configPath, { force: true })
|
||||
session.active = undefined
|
||||
finish()
|
||||
}
|
||||
},
|
||||
cancel: async ({ sessionId }) => {
|
||||
const active = sessions.get(sessionId)?.active
|
||||
active?.abort.abort(new DOMException('Cancelled', 'AbortError'))
|
||||
await active?.finished
|
||||
},
|
||||
closeSession: async ({ sessionId }) => {
|
||||
await agent.cancel({ sessionId })
|
||||
sessions.delete(sessionId)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
const connection = new AgentSideConnection(() => agent, ndJsonStream(
|
||||
Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)
|
||||
))
|
||||
try {
|
||||
if (options.sharedSessions) await connection.extNotification('goodbuddy/modelBridgeReady', { origin })
|
||||
await connection.closed
|
||||
return 0
|
||||
} finally {
|
||||
for (const session of sessions.values()) session.active?.abort.abort()
|
||||
await Promise.all([...sessions.values()].map(session => session.active?.finished))
|
||||
await proxy.close()
|
||||
replies.closeAllConnections()
|
||||
await new Promise<void>(resolve => replies.close(() => resolve()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,34 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('OpenCode direct launch profile', () => {
|
||||
it.each(['ask', 'execute'] as const)('launches Continue through the managed HTTP facade in %s', (workMode) => {
|
||||
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,
|
||||
modelBridge: {
|
||||
agentExecutablePath: resolve(fixture.bundleDirectory, 'agent', 'goodbuddy-agent'),
|
||||
bridgeDirectory: resolve(fixture.bundleDirectory, 'bridge'),
|
||||
socketPath: resolve(fixture.bundleDirectory, 'bridge', 'model.sock'),
|
||||
sharedSessions: true,
|
||||
policy: { protocol: 'openai-responses', model: 'selected-model',
|
||||
modelProfileDigest: `sha256:${'9'.repeat(64)}`, supportsImageInput: true }
|
||||
}
|
||||
})
|
||||
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,
|
||||
'--shared-sessions', 'true'
|
||||
]))
|
||||
expect(profile.args).not.toContain('acp')
|
||||
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) => {
|
||||
|
||||
@@ -45,15 +45,20 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
};
|
||||
}): OpenCodeLaunchProfile {
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest);
|
||||
const isContinue = manifest.runtimeId === "continue" && manifest.provider === "continue";
|
||||
if (
|
||||
manifest.runtimeId !== "opencode" ||
|
||||
!isContinue && (manifest.runtimeId !== "opencode" ||
|
||||
manifest.provider !== "opencode" ||
|
||||
manifest.entrypoint.identity !== "opencode-acp" ||
|
||||
manifest.entrypoint.argvPrefix.length !== 1 ||
|
||||
manifest.entrypoint.argvPrefix[0] !== "acp"
|
||||
manifest.entrypoint.argvPrefix[0] !== "acp")
|
||||
) {
|
||||
throw new Error("Runtime manifest is not the fixed OpenCode ACP profile");
|
||||
}
|
||||
if (isContinue && (manifest.entrypoint.identity !== "continue-acp" ||
|
||||
manifest.entrypoint.path !== "lib/continue/dist/cn.js" || manifest.entrypoint.argvPrefix.length !== 0 || !input.modelBridge)) {
|
||||
throw new Error("Continue requires its managed ACP facade and model bridge");
|
||||
}
|
||||
if (
|
||||
[...manifest.allowedEnvironmentNames].sort().join("\0") !==
|
||||
[...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES].sort().join("\0")
|
||||
@@ -135,25 +140,26 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
"--work-mode",
|
||||
input.workMode,
|
||||
...(modelBridge.sharedSessions ? ["--shared-sessions", "true"] : []),
|
||||
"--opencode-entrypoint",
|
||||
...(isContinue ? ["--runtime-id", "continue"] : []),
|
||||
isContinue ? "--continue-entrypoint" : "--opencode-entrypoint",
|
||||
executablePath,
|
||||
],
|
||||
};
|
||||
|
||||
const environmentBase = {
|
||||
...executeEnvironment(process.env, workspaceDirectory),
|
||||
...OPEN_CODE_RUNTIME_ENVIRONMENT,
|
||||
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode"),
|
||||
...(isContinue ? {} : { ...OPEN_CODE_RUNTIME_ENVIRONMENT,
|
||||
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode") }),
|
||||
};
|
||||
const permission = input.workMode === "ask" ? "ask" : "allow";
|
||||
const environment = {
|
||||
...environmentBase,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
||||
...(isContinue ? {} : { OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
||||
// Native snapshot summaries are not consumed by GoodBuddy.
|
||||
snapshot: false,
|
||||
permission,
|
||||
agent: { build: { permission } },
|
||||
}),
|
||||
}) }),
|
||||
};
|
||||
return {
|
||||
...runtimeCommand,
|
||||
|
||||
@@ -5,6 +5,31 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
|
||||
describe('OpenCode ACP child event plugin', () => {
|
||||
it('forwards native todos intact under their own Session and stops after disposal', async () => {
|
||||
const write = vi.fn()
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
|
||||
{ process: { stdout: { write } }, createServer, randomBytes }
|
||||
)
|
||||
const hooks = await factory()
|
||||
const items = [{ content: 'long'.repeat(2000), status: 'cancelled', priority: 'low' }]
|
||||
for (const sessionID of ['root', 'child']) {
|
||||
for (const todos of [items, []]) {
|
||||
const event = { type: 'todo.updated', properties: { sessionID, todos } }
|
||||
await hooks.event({ event })
|
||||
expect(JSON.parse(write.mock.calls.at(-1)![0]).params).toEqual({
|
||||
sessionId: sessionID, update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-todos',
|
||||
_meta: { goodbuddyTodoEvent: event }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
await hooks.dispose()
|
||||
await hooks.event({ event: { type: 'todo.updated', properties: { sessionID: 'root', todos: [] } } })
|
||||
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)}`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* OpenCode's ACP adapter omits native questions and child sessions. Native
|
||||
* OpenCode's ACP adapter omits native todos, questions and child sessions. Native
|
||||
* events use ACP metadata; question replies use a process-local capability.
|
||||
*/
|
||||
export function openCodeSubagentPluginSource(modelBridgeOrigin?: string): string {
|
||||
@@ -155,6 +155,7 @@ async function plugin(input?: {
|
||||
requestID?: string
|
||||
status?: { type?: string }
|
||||
sessionID?: string
|
||||
todos?: unknown
|
||||
part?: {
|
||||
sessionID?: string
|
||||
type: string
|
||||
@@ -168,6 +169,15 @@ async function plugin(input?: {
|
||||
} }) => {
|
||||
const properties = event.properties
|
||||
if (closed) return
|
||||
if (event.type === 'todo.updated' && properties.sessionID) {
|
||||
// Keep the native Session identity: child todos must not become root todos.
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {
|
||||
sessionId: properties.sessionID,
|
||||
update: { sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-todos',
|
||||
_meta: { goodbuddyTodoEvent: event } }
|
||||
} }) + '\n')
|
||||
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)
|
||||
|
||||
@@ -2638,7 +2638,7 @@ export class RuntimeAcpBackend {
|
||||
): void {
|
||||
const manifest = verified.manifest
|
||||
if (
|
||||
manifest.provider !== 'opencode' ||
|
||||
(manifest.provider !== 'opencode' && manifest.provider !== 'continue') ||
|
||||
manifest.runtimeId !== preparation.runtimeId ||
|
||||
manifest.bundleDigest !== preparation.runtimeBundleDigest ||
|
||||
manifest.adapterDigest !== preparation.runtimeAdapterDigest ||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
AgentSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -35,7 +38,7 @@ import type {
|
||||
RemotePromptOperationReconciliationResult,
|
||||
RemoteRuntimeChannel
|
||||
} from './remote-runtime-channel'
|
||||
import { MemoryRuntimeSessionBindingStore } from './runtime-session-binding-store'
|
||||
import { MemoryRuntimeSessionBindingStore, SqliteRuntimeSessionBindingStore, type RuntimeSessionBindingStore } from './runtime-session-binding-store'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
|
||||
type FakeServer = {
|
||||
@@ -277,7 +280,7 @@ function runtime(
|
||||
|
||||
function factoryRuntime(
|
||||
factory: (bindingId: string) => Promise<RemoteRuntimeChannel>,
|
||||
store = new MemoryRuntimeSessionBindingStore(),
|
||||
store: RuntimeSessionBindingStore = new MemoryRuntimeSessionBindingStore(),
|
||||
overrides: Partial<AcpRemoteRuntimeOptions> = {}
|
||||
): AcpRemoteRuntime {
|
||||
return new AcpRemoteRuntime({
|
||||
@@ -1459,7 +1462,7 @@ describe('AcpRemoteRuntime', () => {
|
||||
'text',
|
||||
'reasoning',
|
||||
'tool',
|
||||
'status',
|
||||
'checklist',
|
||||
'context-metrics',
|
||||
'model-usage',
|
||||
'done'
|
||||
@@ -2591,6 +2594,8 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
}
|
||||
|
||||
function ownedChannel(options?: {
|
||||
runtimeId?: 'opencode' | 'continue'
|
||||
bindingStore?: RuntimeSessionBindingStore
|
||||
updates?: SessionUpdate[]
|
||||
state?: 'completed' | 'failed' | 'cancelled' | 'outcome-unknown'
|
||||
terminalPayload?: unknown
|
||||
@@ -2803,11 +2808,12 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
})),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
const store = options?.bindingStore ?? new MemoryRuntimeSessionBindingStore()
|
||||
const instance = factoryRuntime(
|
||||
async () => channel,
|
||||
store,
|
||||
{
|
||||
runtimeId: options?.runtimeId ?? 'opencode',
|
||||
modelBridgePolicy,
|
||||
modelProfile: ownedModelProfile
|
||||
}
|
||||
@@ -2825,6 +2831,166 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it.each(['continue', 'opencode'] as const)('continues %s after reopening the desktop binding database', async (runtimeId) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'remote-runtime-restart-'))
|
||||
const databasePath = join(directory, 'bindings.sqlite')
|
||||
let store = new SqliteRuntimeSessionBindingStore(databasePath)
|
||||
const first = ownedChannel({ runtimeId, bindingStore: store })
|
||||
let replacement: AcpRemoteRuntime | undefined
|
||||
try {
|
||||
await collect(first.instance.run(request, new AbortController().signal))
|
||||
const original = (await store.getByConversation(request.conversationId))!
|
||||
await first.instance.dispose()
|
||||
store.close()
|
||||
store = new SqliteRuntimeSessionBindingStore(databasePath)
|
||||
const next = ownedChannel({ runtimeId, bindingStore: store })
|
||||
replacement = next.instance
|
||||
if (runtimeId === 'continue') {
|
||||
next.startOwnedPrompt.mockImplementationOnce(async input => {
|
||||
if ('acpSessionId' in input) throw new Error('Continue Session is unavailable')
|
||||
return { bindingId: input.bindingId, operationId: input.operationId, requestId: input.requestId,
|
||||
sessionId: 'owned-session', state: 'running', latestSemanticSequence: '0' }
|
||||
})
|
||||
}
|
||||
const history = [{ role: 'user' as const, content: 'previous question' },
|
||||
{ role: 'assistant' as const, content: 'previous answer' }]
|
||||
const events = await collect(replacement.run({ ...request, requestId: 'second', history }, new AbortController().signal))
|
||||
expect(events.some(event => event.type === 'done')).toBe(true)
|
||||
const resumed = (await store.getByConversation(request.conversationId))!
|
||||
const start = next.startOwnedPrompt.mock.calls[0]![0]
|
||||
if (runtimeId === 'continue') {
|
||||
expect(resumed.bindingId).not.toBe(original.bindingId)
|
||||
expect(await store.getById(original.bindingId)).toMatchObject({ state: 'closed' })
|
||||
expect(start).not.toHaveProperty('acpSessionId')
|
||||
expect(JSON.stringify(start)).toContain('previous answer')
|
||||
expect(resumed.promptSequence).toBe(0)
|
||||
} else {
|
||||
expect(resumed.bindingId).toBe(original.bindingId)
|
||||
expect(start).toHaveProperty('acpSessionId', original.acpSessionId)
|
||||
expect(resumed.promptSequence).toBe(1)
|
||||
}
|
||||
await collect(replacement.run({ ...request, requestId: 'third' }, new AbortController().signal))
|
||||
expect(await store.getByConversation(request.conversationId)).toMatchObject({
|
||||
bindingId: resumed.bindingId, state: 'ready', promptSequence: resumed.promptSequence + 1
|
||||
})
|
||||
expect(next.startOwnedPrompt).toHaveBeenCalledTimes(2)
|
||||
expect(next.startOwnedPrompt.mock.calls[1]![0]).toHaveProperty('acpSessionId', resumed.acpSessionId)
|
||||
expect(next.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await replacement?.dispose()
|
||||
await first.instance.dispose()
|
||||
store.close()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([true, false])('attaches a cached request after start rejection without advancing its prompt sequence (available: %s)', async available => {
|
||||
const fixture = ownedChannel({ runtimeId: 'continue' })
|
||||
fixture.startOwnedPrompt.mockRejectedValueOnce(new Error('Remote Runtime runtime/startPrompt rejected RPC -32602'))
|
||||
try {
|
||||
await expect(collect(fixture.instance.run(request, new AbortController().signal))).rejects.toThrow('runtime/startPrompt')
|
||||
const pending = (await fixture.store.getByConversation(request.conversationId))!
|
||||
fixture.attachOwnedPrompt.mockImplementationOnce(async input => {
|
||||
expect(fixture.channel.close).not.toHaveBeenCalled()
|
||||
if (!available) throw Object.assign(new Error('Remote Runtime runtime/attachPrompt rejected RPC -32602'), {
|
||||
remoteRequestOutcome: 'rejected', remoteMethod: 'runtime/attachPrompt'
|
||||
})
|
||||
return { ...input, sessionId: 'owned-session', state: 'running', latestSemanticSequence: '0' }
|
||||
})
|
||||
const recovery = collect(fixture.instance.run({ ...request, remoteRecoveryOnly: true }, new AbortController().signal))
|
||||
if (available) {
|
||||
expect((await recovery).some(event => event.type === 'done')).toBe(true)
|
||||
} else {
|
||||
await expect(recovery).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
|
||||
}
|
||||
expect(await fixture.store.getByConversation(request.conversationId)).toMatchObject({
|
||||
bindingId: pending.bindingId, state: available ? 'ready' : 'prompt-running', promptSequence: pending.promptSequence
|
||||
})
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.attachOwnedPrompt).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await fixture.instance.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays complete native checklists and clears with stable provenance without starting another prompt', async () => {
|
||||
const items = [{ content: 'full content '.repeat(500).trim(), status: 'cancelled', priority: 'low' }]
|
||||
const native = (todos: unknown, sessionID = 'owned-session'): SessionUpdate => ({
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-todos',
|
||||
_meta: { goodbuddyTodoEvent: { type: 'todo.updated', properties: { sessionID, todos } } }
|
||||
})
|
||||
const fixture = ownedChannel({ updates: [
|
||||
native(items), native([], 'child-session'), native([{ content: 'bad', status: 'failed' }]),
|
||||
{ sessionUpdate: 'tool_call', toolCallId: 'todo-tool', title: 'todowrite', status: 'completed', rawInput: { todos: [] } },
|
||||
native([])
|
||||
] })
|
||||
const first: RuntimeEvent[] = []
|
||||
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
|
||||
first.push(event)
|
||||
if (event.type === 'done') break
|
||||
}
|
||||
const replacement = factoryRuntime(async () => fixture.channel, fixture.store, { modelBridgePolicy, modelProfile: ownedModelProfile })
|
||||
try {
|
||||
const replay = await collect(replacement.run({ ...request, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '0' }, new AbortController().signal))
|
||||
const checklists = first.filter(event => event.type === 'checklist')
|
||||
expect(checklists).toHaveLength(2)
|
||||
expect(checklists[0]).toMatchObject({ requestId: request.requestId, checklist: { source: 'opencode', items } })
|
||||
expect(checklists[1]).toMatchObject({ checklist: { source: 'opencode', items: [] } })
|
||||
expect(replay.filter(event => event.type === 'checklist')).toEqual(checklists)
|
||||
expect(first.filter(event => event.type === 'tool')).toHaveLength(1)
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledTimes(1)
|
||||
expect(fixture.attachOwnedPrompt).toHaveBeenCalledTimes(1)
|
||||
} finally { await replacement.dispose(); await fixture.instance.dispose() }
|
||||
})
|
||||
|
||||
it('replays complete Continue checklists and ignores wrong-source and malformed updates', async () => {
|
||||
const items = [{ content: 'complete checklist content '.repeat(300).trim(), status: 'pending' }]
|
||||
const native = (checklist: unknown): SessionUpdate => ({
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-checklist',
|
||||
_meta: { goodbuddyChecklist: checklist as never }
|
||||
})
|
||||
const fixture = ownedChannel({ runtimeId: 'continue', updates: [
|
||||
native({ source: 'continue', items }), native({ source: 'opencode', items: [] }),
|
||||
native({ source: 'continue', items: [{ content: 'bad', status: 'failed' }] }),
|
||||
{ sessionUpdate: 'tool_call', toolCallId: 'native-tool', title: 'Checklist', status: 'completed', rawInput: { checklist: '' } },
|
||||
native({ source: 'continue', items: [] })
|
||||
] })
|
||||
const first: RuntimeEvent[] = []
|
||||
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
|
||||
first.push(event)
|
||||
if (event.type === 'done') break
|
||||
}
|
||||
const replacement = factoryRuntime(async () => fixture.channel, fixture.store, {
|
||||
runtimeId: 'continue', modelBridgePolicy, modelProfile: ownedModelProfile
|
||||
})
|
||||
try {
|
||||
const replay = await collect(replacement.run({ ...request, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '0' }, new AbortController().signal))
|
||||
const checklists = first.filter(event => event.type === 'checklist')
|
||||
expect(checklists).toHaveLength(2)
|
||||
expect(checklists[0]).toMatchObject({ checklist: { source: 'continue', items } })
|
||||
expect(checklists[1]).toMatchObject({ checklist: { source: 'continue', items: [] } })
|
||||
expect(replay.filter(event => event.type === 'checklist')).toEqual(checklists)
|
||||
expect(first.filter(event => event.type === 'tool')).toHaveLength(1)
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
|
||||
} finally { await replacement.dispose(); await fixture.instance.dispose() }
|
||||
})
|
||||
|
||||
it('keeps unconfirmed plan variants as activity, not checklist replacement', async () => {
|
||||
const fixture = ownedChannel({ updates: [
|
||||
{ sessionUpdate: 'plan', entries: [] },
|
||||
{ sessionUpdate: 'plan', entries: [null] } as unknown as SessionUpdate,
|
||||
{ sessionUpdate: 'plan', entries: [{ content: 'bad', status: 'failed', priority: 'high' }] } as unknown as SessionUpdate,
|
||||
{ sessionUpdate: 'plan_removed', id: 'plan' },
|
||||
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '- [x] not a native checklist' } }
|
||||
] })
|
||||
const events = await collect(fixture.instance.run(request, new AbortController().signal))
|
||||
expect(events.filter(event => event.type === 'checklist')).toEqual([
|
||||
expect.objectContaining({ checklist: { source: 'opencode', items: [] } })
|
||||
])
|
||||
await fixture.instance.dispose()
|
||||
})
|
||||
|
||||
it('starts once, maps multiple rounds, and ACKs only after consumption', async () => {
|
||||
const fixture = ownedChannel({
|
||||
includePermissionCheckpoint: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { remoteImageToolMcpName } from '../../shared/remote-image-tool-node'
|
||||
import { remoteQuestionSchema } from '../../shared/remote-question-contracts'
|
||||
import { runtimeChecklistSchema } from '../../shared/runtime-checklist'
|
||||
import type { AgentQuestionAnswer } from '../../shared/contracts'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
@@ -1404,7 +1405,9 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
let bindingId = binding?.bindingId ?? randomUUID()
|
||||
if (
|
||||
binding?.state === 'ready' &&
|
||||
!this.bindingIdentityMatches(binding)
|
||||
// Continue helper sessions live only in the remote process. A cold open
|
||||
// must seed a new session from conversation history after that process exits.
|
||||
(this.options.runtimeId === 'continue' || !this.bindingIdentityMatches(binding))
|
||||
) {
|
||||
await this.closeIdleBinding(binding, signal)
|
||||
binding = undefined
|
||||
@@ -1743,6 +1746,16 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
signal: AbortSignal
|
||||
): Promise<SessionRecord> {
|
||||
let current = this.sessions.get(conversationId)
|
||||
if (
|
||||
current?.binding.activePromptOperationId &&
|
||||
this.options.modelProfile !== undefined &&
|
||||
!current.ownedPromptAttached
|
||||
) {
|
||||
// A failed start has no confirmed attachment. Reopen through attach using
|
||||
// the persisted operation, without advancing its sequence or closing its channel.
|
||||
this.sessions.delete(conversationId)
|
||||
current = undefined
|
||||
}
|
||||
if (
|
||||
current?.binding.activePromptOperationId &&
|
||||
this.options.modelProfile === undefined
|
||||
@@ -2153,8 +2166,23 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
prompt: ActivePrompt,
|
||||
update: SessionUpdate
|
||||
): RuntimePublicEvent | undefined {
|
||||
if (update._meta?.goodbuddyChecklist !== undefined) {
|
||||
const checklist = runtimeChecklistSchema.safeParse(update._meta.goodbuddyChecklist)
|
||||
if (this.options.runtimeId !== 'continue' || !prompt.open || !checklist.success || checklist.data.source !== 'continue') return undefined
|
||||
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
|
||||
}
|
||||
const todoEvent = update._meta?.goodbuddyTodoEvent
|
||||
if (todoEvent !== undefined) {
|
||||
if (this.options.runtimeId !== 'opencode' || !prompt.open ||
|
||||
!todoEvent || typeof todoEvent !== 'object') return undefined
|
||||
const native = todoEvent as { type?: unknown; properties?: { sessionID?: unknown; todos?: unknown } }
|
||||
if (native.type !== 'todo.updated' || native.properties?.sessionID !== prompt.sessionId) return undefined
|
||||
const checklist = runtimeChecklistSchema.safeParse({ source: 'opencode', items: native.properties.todos })
|
||||
if (!checklist.success) return undefined
|
||||
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
|
||||
}
|
||||
const extension = update._meta?.goodbuddyQuestion
|
||||
if (this.options.runtimeId === 'opencode' && extension && typeof extension === 'object' &&
|
||||
if ((this.options.runtimeId === 'opencode' || this.options.runtimeId === 'continue') && extension && typeof extension === 'object' &&
|
||||
prompt.open && prompt.context.channel.respondToQuestion) {
|
||||
const value = extension as { question?: unknown; childCallId?: unknown }
|
||||
const question = remoteQuestionSchema.parse(value.question)
|
||||
@@ -2348,6 +2376,15 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
if (update.sessionUpdate === 'plan') {
|
||||
if (this.options.runtimeId === 'opencode') {
|
||||
if (!Array.isArray(update.entries) || update.entries.some(entry => !entry || typeof entry !== 'object')) return undefined
|
||||
const checklist = runtimeChecklistSchema.safeParse({
|
||||
source: 'opencode',
|
||||
items: update.entries.map(({ content, status, priority }) => ({ content, status, priority }))
|
||||
})
|
||||
if (!checklist.success) return undefined
|
||||
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
|
||||
}
|
||||
return {
|
||||
requestId: prompt.requestId,
|
||||
type: 'status',
|
||||
|
||||
@@ -117,6 +117,44 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ContinueHostAdapter', () => {
|
||||
it('commits complete raw checklists once on success, retaining candidates and excluding old history', async () => {
|
||||
const distribution = await createDistribution()
|
||||
const longContent = 'task'.repeat(1500)
|
||||
const checklist = { source: 'continue' as const, items: [{ content: longContent, status: 'pending' as const }] }
|
||||
const historyTool = (id: string, text: string, status = 'done') => ({
|
||||
toolCallId: id, toolCall: { function: { name: 'Checklist', arguments: JSON.stringify({ checklist: text }) } }, status
|
||||
})
|
||||
const old = { toolCallStates: [historyTool('old', '- [ ] old')] }
|
||||
let polls = 0
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
|
||||
if (!String(input).endsWith('/state')) return Response.json({})
|
||||
polls++
|
||||
return Response.json({
|
||||
session: { history: polls === 1 ? [old] : [old, {
|
||||
message: { role: 'assistant', content: 'OK' },
|
||||
toolCallStates: polls < 3 ? [] : [historyTool('long', `- [ ] ${longContent}`), historyTool('failed', '- [x] bad', 'failed'), ...(polls >= 4 ? [historyTool('empty', '')] : [])]
|
||||
}] },
|
||||
isProcessing: polls < 5, messageQueueLength: 0, pendingPermission: null,
|
||||
goodbuddyEvents: polls === 2 ? [
|
||||
{ type: 'tool', callId: 'long', name: 'Checklist', state: 'running', input: 'truncated', runtimeChecklist: checklist },
|
||||
{ type: 'tool', callId: 'failed', name: 'Checklist', state: 'running', runtimeChecklist: checklist }
|
||||
] : polls === 3 ? [
|
||||
{ type: 'tool', callId: 'long', name: 'Checklist', state: 'completed' },
|
||||
{ type: 'tool', callId: 'failed', name: 'Checklist', state: 'failed' }
|
||||
] : []
|
||||
})
|
||||
}))
|
||||
const events: unknown[] = []
|
||||
const adapter = new ContinueHostAdapter({
|
||||
binaryPath: distribution.entryPath, configPath: 'test.yaml', workspace: process.cwd(), cacheRoot: distribution.cacheRoot,
|
||||
trustedBundleHashes: [distribution.sourceHash],
|
||||
launchHost: () => ({ exitCode: null, killed: false, stderr: null, once: () => undefined, kill: () => true })
|
||||
})
|
||||
try {
|
||||
await adapter.run('test', new AbortController().signal, async () => 'once', { onEvent: event => { if (event.type === 'checklist') events.push(event) } })
|
||||
expect(events).toEqual([{ type: 'checklist', checklist }, { type: 'checklist', checklist: { source: 'continue', items: [] } }])
|
||||
} finally { await adapter.dispose() }
|
||||
})
|
||||
it('creates a versioned authenticated loopback host copy', async () => {
|
||||
const distribution = await createDistribution()
|
||||
const adapter = new ContinueHostAdapter({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import spawn from 'cross-spawn'
|
||||
import { parseContinueChecklist, runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist'
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import {
|
||||
@@ -113,6 +114,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
|
||||
name: z.string().min(1).max(200),
|
||||
state: z.enum(['running', 'completed', 'failed']),
|
||||
input: z.string().max(4_000).optional(),
|
||||
runtimeChecklist: runtimeChecklistSchema.optional(),
|
||||
summary: z.string().max(240).optional(),
|
||||
output: z.string().max(16_000).optional(),
|
||||
error: z.string().max(1_000).optional()
|
||||
@@ -179,6 +181,7 @@ export type ContinueHostUsage = {
|
||||
}
|
||||
|
||||
export type ContinueHostTool = {
|
||||
runtimeChecklist?: RuntimeChecklist
|
||||
callId: string
|
||||
name: string
|
||||
state: 'pending' | 'running' | 'completed' | 'failed'
|
||||
@@ -197,6 +200,7 @@ export type ContinueHostRunResult = {
|
||||
}
|
||||
|
||||
export type ContinueHostStreamEvent =
|
||||
| { type: 'checklist'; checklist: RuntimeChecklist }
|
||||
| { type: 'text'; delta: string }
|
||||
| { type: 'tool'; tool: ContinueHostTool }
|
||||
| {
|
||||
@@ -729,6 +733,7 @@ function extractContinueTools(
|
||||
? boundedToolDetail(state.output, 16_000)
|
||||
: undefined
|
||||
tools.set(callId, {
|
||||
runtimeChecklist: parseContinueChecklist(name, (toolFunction as Record<string, unknown>).arguments),
|
||||
callId,
|
||||
name: name.trim().slice(0, 200),
|
||||
state: normalizedState,
|
||||
@@ -756,6 +761,7 @@ function mergeContinueTools(
|
||||
...previous,
|
||||
...tool,
|
||||
input: tool.input ?? previous?.input,
|
||||
runtimeChecklist: tool.runtimeChecklist ?? previous?.runtimeChecklist,
|
||||
summary: tool.summary ?? previous?.summary,
|
||||
output: tool.output ?? previous?.output,
|
||||
error: tool.error ?? previous?.error
|
||||
@@ -987,7 +993,7 @@ export class ContinueHostAdapter {
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
'state:"running",input:(()=>',
|
||||
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
|
||||
`state:"running",runtimeChecklist:(${parseContinueChecklist.toString()})(u,l),summary:(${toolOperationSummary.toString()})(l),input:(()=>`
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
@@ -1600,6 +1606,7 @@ export class ContinueHostAdapter {
|
||||
let streamTruncated = false
|
||||
let streamEventBytes = 0
|
||||
const observedToolCallIds = new Set<string>()
|
||||
const submittedChecklists = new Set<string>()
|
||||
try {
|
||||
const initialState = await this.waitForStartup(
|
||||
child,
|
||||
@@ -1692,6 +1699,7 @@ export class ContinueHostAdapter {
|
||||
continue
|
||||
}
|
||||
const tool: ContinueHostTool = {
|
||||
runtimeChecklist: event.runtimeChecklist,
|
||||
callId: event.callId,
|
||||
name: event.name,
|
||||
state: event.state,
|
||||
@@ -1712,6 +1720,12 @@ export class ContinueHostAdapter {
|
||||
tool: observedTools.find((item) => item.callId === tool.callId)!
|
||||
})
|
||||
}
|
||||
for (const tool of observedTools) {
|
||||
if (tool.name === 'Checklist' && tool.state === 'completed' && tool.runtimeChecklist && !submittedChecklists.has(tool.callId)) {
|
||||
submittedChecklists.add(tool.callId)
|
||||
await runOptions.onEvent?.({ type: 'checklist', checklist: tool.runtimeChecklist })
|
||||
}
|
||||
}
|
||||
const pendingQuestion = state.goodbuddyQuestion
|
||||
if (
|
||||
pendingQuestion &&
|
||||
|
||||
@@ -492,6 +492,22 @@ describe('ContinueAgentRuntime', () => {
|
||||
} finally { await runtime.dispose() }
|
||||
})
|
||||
|
||||
it('forwards committed checklist replacements with the request identity including clears', async () => {
|
||||
mocks.runHost.mockImplementation(async (_prompt, _signal, _authorize, options) => {
|
||||
options.onEvent({ type: 'checklist', checklist: { source: 'continue', items: [{ content: 'task', status: 'pending' }] } })
|
||||
options.onEvent({ type: 'checklist', checklist: { source: 'continue', items: [] } })
|
||||
return { text: 'OK' }
|
||||
})
|
||||
const runtime = createRuntime()
|
||||
try {
|
||||
const events = await collectEvents(runtime, 'execute')
|
||||
expect(events.filter(event => event.type === 'checklist')).toEqual([
|
||||
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'continue', items: [{ content: 'task', status: 'pending' }] } },
|
||||
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'continue', items: [] } }
|
||||
])
|
||||
} finally { await runtime.dispose() }
|
||||
})
|
||||
|
||||
it('adds assigned Skill instructions to the Continue prompt', async () => {
|
||||
let hostOptions: ContinueHostAdapterOptions | undefined
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
|
||||
@@ -571,6 +571,8 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
type: 'text',
|
||||
delta: event.delta
|
||||
}
|
||||
} else if (event.type === 'checklist') {
|
||||
yield { type: 'checklist', requestId: request.requestId, checklist: event.checklist }
|
||||
} else if (event.type === 'tool') {
|
||||
yield toContinueToolEvent(
|
||||
request.requestId,
|
||||
|
||||
@@ -288,6 +288,45 @@ async function collectRun(
|
||||
}
|
||||
|
||||
describe("OpenCodeRuntime embedded launcher", () => {
|
||||
it('attributes todos to the current parent message and running call in reused sessions', async () => {
|
||||
const setup = runClient([]);
|
||||
let previousMessage = 'old-message';
|
||||
const items = [{ content: 'task', status: 'pending', priority: 'low' }];
|
||||
vi.mocked(setup.event.subscribe).mockImplementation(async () => ({
|
||||
stream: (async function* () {
|
||||
const current = vi.mocked(setup.session.promptAsync).mock.calls.at(-1)![0]!.messageID!;
|
||||
const update = (parentID: string) => ({ type: 'message.updated', properties: { sessionID: 'session-1', info: { id: parentID + '-assistant', sessionID: 'session-1', role: 'assistant', parentID, time: { created: Date.now() } } } });
|
||||
const tool = (parentID: string, todos: unknown[], status = 'running', callID = 'call') => ({ type: 'message.part.updated', properties: { sessionID: 'session-1', part: { id: 'part', callID, sessionID: 'session-1', messageID: parentID + '-assistant', type: 'tool', tool: 'todowrite', state: { status, input: { todos } } } } });
|
||||
const todo = (sessionID: string, todos: unknown[]) => ({ type: 'todo.updated', properties: { sessionID, todos } });
|
||||
yield update(previousMessage);
|
||||
yield tool(previousMessage, items);
|
||||
yield todo('session-1', items);
|
||||
yield update(current);
|
||||
yield tool(current, [], 'pending');
|
||||
yield todo('session-1', items);
|
||||
yield tool(current, items);
|
||||
yield todo('child-session', items);
|
||||
yield todo('session-1', [{ content: 'late old task', status: 'pending' }]);
|
||||
yield todo('session-1', items);
|
||||
yield completedToolEvent('call', 'todowrite');
|
||||
yield tool(current, [], 'running', 'clear');
|
||||
yield todo('session-1', []);
|
||||
yield completedToolEvent('clear', 'todowrite');
|
||||
previousMessage = current;
|
||||
yield { type: 'session.idle', properties: { sessionID: 'session-1' } };
|
||||
})()
|
||||
}) as never);
|
||||
const runtime = embeddedRuntime(setup.client);
|
||||
try {
|
||||
for (let round = 0; round < 2; round++) {
|
||||
const events = await collectRun(runtime);
|
||||
expect(events.filter(event => event.type === 'checklist')).toEqual([
|
||||
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items } },
|
||||
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items: [] } }
|
||||
]);
|
||||
}
|
||||
} finally { await runtime.dispose(); }
|
||||
});
|
||||
it.each(["native", "profile"] as const)(
|
||||
"disables unused automatic Git snapshots with %s model configuration",
|
||||
async (configuration) => {
|
||||
@@ -4831,6 +4870,7 @@ describe("OpenCodeRuntime native customization", () => {
|
||||
command: "review",
|
||||
arguments: "--staged",
|
||||
agent: "build",
|
||||
messageID: expect.stringMatching(/^msg_/u),
|
||||
},
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist';
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage,
|
||||
@@ -2445,6 +2446,13 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
};
|
||||
};
|
||||
let hasResponseTextAfterFailure = false;
|
||||
// Match OC's sortable 48-bit timestamp prefix so reused-session history stays ordered.
|
||||
const messageTime = ((BigInt(Date.now()) << 12n) & 0xffffffffffffn).toString(16).padStart(12, '0');
|
||||
const promptMessageId = `msg_${messageTime}${crypto.randomUUID().replaceAll('-', '').slice(0, 14)}`;
|
||||
const checklistMessageIds = new Set<string>();
|
||||
const checklistCalls = new Map<string, unknown>();
|
||||
const submittedChecklistCalls = new Set<string>();
|
||||
const pendingChecklistUpdates: RuntimeChecklist[] = [];
|
||||
try {
|
||||
const promptText = promptWithUntrustedConversationHistory(
|
||||
request,
|
||||
@@ -2462,6 +2470,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
command: selectedCommand.name,
|
||||
messageID: promptMessageId,
|
||||
arguments: selectedCommand.arguments,
|
||||
...(selectedAgent ? { agent: selectedAgent } : {}),
|
||||
...(this.options.modelProfile
|
||||
@@ -2482,6 +2491,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
messageID: promptMessageId,
|
||||
model: this.options.modelProfile
|
||||
? {
|
||||
providerID: resolveOpenCodeProvider(
|
||||
@@ -2538,6 +2548,43 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
subscription.stream,
|
||||
subscriptionController,
|
||||
)) {
|
||||
if (signal.aborted) break;
|
||||
if (event.type === 'message.updated' &&
|
||||
event.properties.info.sessionID === sessionId &&
|
||||
event.properties.info.role === 'assistant' &&
|
||||
event.properties.info.parentID === promptMessageId) {
|
||||
checklistMessageIds.add(event.properties.info.id);
|
||||
}
|
||||
if (event.type === 'message.part.updated') {
|
||||
const part = event.properties.part;
|
||||
if (part.sessionID === sessionId && part.type === 'tool' &&
|
||||
part.tool === 'todowrite' && checklistMessageIds.has(part.messageID)) {
|
||||
if (part.state.status === 'pending' && !submittedChecklistCalls.has(part.callID)) {
|
||||
checklistCalls.set(part.callID, undefined);
|
||||
} else if (part.state.status === 'running' && !submittedChecklistCalls.has(part.callID)) {
|
||||
checklistCalls.set(part.callID, part.state.input.todos);
|
||||
} else {
|
||||
checklistCalls.delete(part.callID);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.type === 'todo.updated' && event.properties.sessionID === sessionId) {
|
||||
const parsed = runtimeChecklistSchema.safeParse({ source: 'opencode', items: event.properties.todos });
|
||||
if (parsed.success && checklistCalls.size > 0) pendingChecklistUpdates.push(parsed.data);
|
||||
}
|
||||
// OC 1.18.29 publishes todo.updated before the tool's running input frame.
|
||||
for (const [callId, items] of checklistCalls) {
|
||||
const candidate = runtimeChecklistSchema.safeParse({ source: 'opencode', items });
|
||||
if (!candidate.success) continue;
|
||||
const index = pendingChecklistUpdates.findIndex(update => JSON.stringify(update) === JSON.stringify(candidate.data));
|
||||
if (index >= 0) {
|
||||
const checklist = pendingChecklistUpdates.splice(index, 1)[0]!;
|
||||
submittedChecklistCalls.add(callId);
|
||||
checklistCalls.delete(callId);
|
||||
yield { type: 'checklist', requestId: request.requestId, checklist };
|
||||
}
|
||||
}
|
||||
if (checklistCalls.size === 0) pendingChecklistUpdates.length = 0;
|
||||
const childProgress = subagentProgress.update(event);
|
||||
if (
|
||||
childProgress &&
|
||||
|
||||
@@ -263,6 +263,25 @@ function claimManualScheduleQueueItem(
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it('round trips long checklist metadata and explicit clears across reopen', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-checklist-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
await database.initialize(process.cwd())
|
||||
const id = randomUUID()
|
||||
const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const, content: '', createdAt: 1,
|
||||
runtimeChecklist: { source: 'continue' as const, items: [{ content: 'task'.repeat(1500), status: 'pending' as const }] } }
|
||||
database.replaceConversations([{ id, title: 'Checklist', updatedAt: 1, messages: [message] }])
|
||||
database.close()
|
||||
const reopened = new AssistantDatabase(path)
|
||||
await reopened.initialize(process.cwd())
|
||||
try {
|
||||
expect(reopened.getConversation(id).messages[0]?.runtimeChecklist).toEqual(message.runtimeChecklist)
|
||||
reopened.saveLocalConversations([{ header: { id, title: 'Checklist', updatedAt: 2 }, messages: [{ ...message, runtimeChecklist: { source: 'continue', items: [] } }] }])
|
||||
expect(reopened.getConversation(id).messages[0]?.runtimeChecklist).toEqual({ source: 'continue', items: [] })
|
||||
} finally { reopened.close() }
|
||||
})
|
||||
it('lists lightweight history without dropping searchable text or full process metadata', async () => {
|
||||
const database = await createDatabase()
|
||||
try {
|
||||
@@ -3828,6 +3847,13 @@ describe('AssistantDatabase', () => {
|
||||
}))
|
||||
]
|
||||
})
|
||||
const checklistEvent = { requestId: taskId, type: 'checklist' as const,
|
||||
checklist: { source: 'opencode' as const, items: [{ content: 'task', status: 'in_progress' as const }] } }
|
||||
expect(() => append('7', 1, { ...checklistEvent, checklist: { source: 'invalid' as never, items: [] } })).toThrow()
|
||||
expect(append('7', 1, checklistEvent)).toBe(true)
|
||||
expect(append('7', 1, checklistEvent)).toBe(false)
|
||||
expect(database.getConversation(conversationId).messages[1]?.runtimeChecklist).toEqual(checklistEvent.checklist)
|
||||
expect(append('7', 2, { ...checklistEvent, checklist: { source: 'opencode', items: [] } })).toBe(true)
|
||||
append('8', 0, {
|
||||
requestId: taskId,
|
||||
type: 'done'
|
||||
@@ -3845,6 +3871,7 @@ describe('AssistantDatabase', () => {
|
||||
expect(recoveredMessage).toMatchObject({
|
||||
content: recoveredText,
|
||||
reasoning: '先验证状态',
|
||||
runtimeChecklist: { source: 'opencode', items: [] },
|
||||
state: 'complete',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000707'
|
||||
@@ -3961,6 +3988,7 @@ describe('AssistantDatabase', () => {
|
||||
id: assistantMessageId,
|
||||
content: recoveredText,
|
||||
state: 'error',
|
||||
runtimeChecklist: { source: 'opencode', items: [] },
|
||||
status: '远端请求已不存在'
|
||||
})
|
||||
expect(reopened.listRecoverableRemoteTasks()).toEqual([])
|
||||
|
||||
@@ -275,6 +275,7 @@ type MessageMetadata = {
|
||||
queueItemId?: string
|
||||
status?: string
|
||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||
runtimeChecklist?: ConversationMessage['runtimeChecklist']
|
||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||
displayCaptureTruncated?: boolean
|
||||
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
||||
@@ -1171,6 +1172,7 @@ function toConversationSnapshot(
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
reasoning: metadata.reasoning,
|
||||
runtimeChecklist: metadata.runtimeChecklist,
|
||||
blocks: metadata.blocks,
|
||||
displayCaptureTruncated:
|
||||
metadata.displayCaptureTruncated,
|
||||
@@ -1205,6 +1207,7 @@ function serializeConversationMessageMetadata(
|
||||
queueItemId: message.queueItemId,
|
||||
status: message.status,
|
||||
reasoning: message.reasoning,
|
||||
runtimeChecklist: message.runtimeChecklist,
|
||||
blocks: message.blocks,
|
||||
displayCaptureTruncated: message.displayCaptureTruncated,
|
||||
contextCompression: message.contextCompression,
|
||||
@@ -1373,6 +1376,8 @@ function reduceRecoveredAgentEvent(
|
||||
blocks,
|
||||
status: undefined
|
||||
}
|
||||
} else if (event.type === 'checklist') {
|
||||
next = { ...message, runtimeChecklist: event.checklist }
|
||||
} else if (event.type === 'status') {
|
||||
next = { ...message, status: event.message }
|
||||
} else if (event.type === 'context-compression') {
|
||||
@@ -5015,6 +5020,7 @@ export class AssistantDatabase {
|
||||
createdAt: metadata.createdAt ?? Date.parse(row.created_at),
|
||||
status: metadata.status,
|
||||
reasoning: metadata.reasoning,
|
||||
runtimeChecklist: metadata.runtimeChecklist,
|
||||
blocks: metadata.blocks,
|
||||
displayCaptureTruncated:
|
||||
metadata.displayCaptureTruncated,
|
||||
@@ -5429,6 +5435,7 @@ export class AssistantDatabase {
|
||||
metadata.createdAt ?? Date.parse(row.created_at),
|
||||
status: metadata.status,
|
||||
reasoning: metadata.reasoning,
|
||||
runtimeChecklist: metadata.runtimeChecklist,
|
||||
blocks: metadata.blocks,
|
||||
displayCaptureTruncated:
|
||||
metadata.displayCaptureTruncated,
|
||||
|
||||
+7
-5
@@ -674,11 +674,13 @@ if (hasSingleInstanceLock) {
|
||||
await settingsStore.getResolvedSettings(),
|
||||
selection
|
||||
),
|
||||
resolveModelProfile: async (selection) =>
|
||||
applyRuntimeSelection(
|
||||
resolveModelProfile: async (selection) => {
|
||||
const resolved = applyRuntimeSelection(
|
||||
await settingsStore.getResolvedSettings(),
|
||||
selection
|
||||
).settings.opencodeModelProfile
|
||||
).settings
|
||||
return selection.provider === 'continue' ? resolved.continueModelProfile : resolved.opencodeModelProfile
|
||||
}
|
||||
})
|
||||
await startupManagedRemoteExecutionServices.initialize()
|
||||
managedRemoteExecutionServices =
|
||||
@@ -1081,10 +1083,10 @@ if (hasSingleInstanceLock) {
|
||||
selection
|
||||
)
|
||||
if (executionSpace?.kind === 'ssh') {
|
||||
const profile = resolved.settings.opencodeModelProfile
|
||||
const profile = selection.provider === 'continue' ? resolved.settings.continueModelProfile : resolved.settings.opencodeModelProfile
|
||||
if (profile === undefined) {
|
||||
throw new Error(
|
||||
'托管远程 OpenCode 需要选择一个可用的文本模型配置'
|
||||
'托管远程 Runtime 需要选择一个可用的文本模型配置'
|
||||
)
|
||||
}
|
||||
return await startupManagedRemoteExecutionServices.createRuntime({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { agentPlatformSchema, type AgentPlatform } from '../../shared/agent-target'
|
||||
import {
|
||||
agentPackageArchiveName,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
agentPackageInventorySchema,
|
||||
type AgentPackageCatalog,
|
||||
type AgentPackageCatalogEntry,
|
||||
type AgentPackageDescriptor,
|
||||
type AgentPackageDownloadProgress,
|
||||
type AgentPackageInventory
|
||||
} from '../../shared/agent-package-contracts'
|
||||
@@ -105,6 +107,7 @@ export type AgentPackageManagerOptions = {
|
||||
}
|
||||
|
||||
export type VerifiedRemoteAgentInstallCandidate = {
|
||||
additionalRuntimes?: AgentPackageDescriptor['additionalRuntimes']
|
||||
source: UpdateSource
|
||||
platform: AgentPlatform
|
||||
architecture: AgentArchitecture
|
||||
@@ -115,8 +118,8 @@ export type VerifiedRemoteAgentInstallCandidate = {
|
||||
minor: number
|
||||
}
|
||||
remoteRuntime: {
|
||||
runtimeId: 'opencode'
|
||||
provider: 'opencode'
|
||||
runtimeId: 'opencode' | 'continue'
|
||||
provider: 'opencode' | 'continue'
|
||||
version: string
|
||||
bundleDigest: string
|
||||
protocol: {
|
||||
@@ -138,8 +141,8 @@ export type VerifiedRemoteAgentEnvironmentCatalog = {
|
||||
expected: {
|
||||
agent: { version: string }
|
||||
runtimes: Array<{
|
||||
runtimeId: 'opencode'
|
||||
provider: 'opencode'
|
||||
runtimeId: 'opencode' | 'continue'
|
||||
provider: 'opencode' | 'continue'
|
||||
version: string
|
||||
}>
|
||||
}
|
||||
@@ -170,15 +173,17 @@ export type AcquireInstallArchiveOptions = {
|
||||
|
||||
function expectedCatalog(
|
||||
agentVersion: string,
|
||||
runtimeVersion: string
|
||||
runtimeVersion: string,
|
||||
runtimeId: 'opencode' | 'continue' = 'opencode',
|
||||
additionalRuntimes: NonNullable<AgentPackageDescriptor['additionalRuntimes']> = []
|
||||
): VerifiedRemoteAgentEnvironmentCatalog['expected'] {
|
||||
return {
|
||||
agent: { version: agentVersion },
|
||||
runtimes: [{
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
runtimeId,
|
||||
provider: runtimeId,
|
||||
version: runtimeVersion
|
||||
}]
|
||||
}, ...additionalRuntimes.map(({ runtimeId, provider, version }) => ({ runtimeId, provider, version }))]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +198,7 @@ function candidateFromCatalogEntry(
|
||||
version: entry.version,
|
||||
minimumDesktopVersion: entry.minimumDesktopVersion,
|
||||
agentProtocol: { ...entry.agentProtocol },
|
||||
...(entry.additionalRuntimes ? { additionalRuntimes: structuredClone(entry.additionalRuntimes) } : {}),
|
||||
remoteRuntime: {
|
||||
...entry.remoteRuntime,
|
||||
protocol: { ...entry.remoteRuntime.protocol }
|
||||
@@ -217,6 +223,7 @@ function candidateFromVerifiedRecord(
|
||||
version: descriptor.version,
|
||||
minimumDesktopVersion: descriptor.minimumDesktopVersion,
|
||||
agentProtocol: { ...descriptor.agentProtocol },
|
||||
...(descriptor.additionalRuntimes ? { additionalRuntimes: structuredClone(descriptor.additionalRuntimes) } : {}),
|
||||
remoteRuntime: {
|
||||
...descriptor.remoteRuntime,
|
||||
protocol: { ...descriptor.remoteRuntime.protocol }
|
||||
@@ -399,7 +406,9 @@ export class AgentPackageManager {
|
||||
return {
|
||||
expected: expectedCatalog(
|
||||
installed.verified.descriptor.version,
|
||||
installed.verified.descriptor.remoteRuntime.version
|
||||
installed.verified.descriptor.remoteRuntime.version,
|
||||
installed.verified.descriptor.remoteRuntime.runtimeId,
|
||||
installed.verified.descriptor.additionalRuntimes
|
||||
),
|
||||
candidate: null,
|
||||
candidateFailure: {
|
||||
@@ -414,7 +423,9 @@ export class AgentPackageManager {
|
||||
}
|
||||
const expected = expectedCatalog(
|
||||
entry.version,
|
||||
entry.remoteRuntime.version
|
||||
entry.remoteRuntime.version,
|
||||
entry.remoteRuntime.runtimeId,
|
||||
entry.additionalRuntimes
|
||||
)
|
||||
try {
|
||||
return {
|
||||
@@ -710,6 +721,7 @@ export class AgentPackageManager {
|
||||
candidate.agentProtocol.major &&
|
||||
descriptor.agentProtocol.minor ===
|
||||
candidate.agentProtocol.minor &&
|
||||
isDeepStrictEqual(descriptor.additionalRuntimes ?? [], candidate.additionalRuntimes ?? []) &&
|
||||
descriptor.remoteRuntime.runtimeId ===
|
||||
candidate.remoteRuntime.runtimeId &&
|
||||
descriptor.remoteRuntime.provider ===
|
||||
@@ -835,6 +847,7 @@ export class AgentPackageManager {
|
||||
entry.agentProtocol.major ||
|
||||
descriptor.agentProtocol.minor !==
|
||||
entry.agentProtocol.minor ||
|
||||
!isDeepStrictEqual(descriptor.additionalRuntimes ?? [], entry.additionalRuntimes ?? []) ||
|
||||
descriptor.remoteRuntime.runtimeId !==
|
||||
entry.remoteRuntime.runtimeId ||
|
||||
descriptor.remoteRuntime.provider !==
|
||||
@@ -1981,6 +1994,7 @@ function assertCandidateMatchesCatalogEntry(
|
||||
entry.agentProtocol.major ||
|
||||
candidate.agentProtocol.minor !==
|
||||
entry.agentProtocol.minor ||
|
||||
!isDeepStrictEqual(candidate.additionalRuntimes ?? [], entry.additionalRuntimes ?? []) ||
|
||||
candidate.remoteRuntime.runtimeId !==
|
||||
entry.remoteRuntime.runtimeId ||
|
||||
candidate.remoteRuntime.provider !==
|
||||
@@ -2218,4 +2232,4 @@ async function writeAll(
|
||||
}
|
||||
offset += bytesWritten
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,30 +223,35 @@ export async function verifyExtractedAgentPackage(options: {
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const runtime of [descriptor.remoteRuntime, ...(descriptor.additionalRuntimes ?? [])]) {
|
||||
const runtimeBundle =
|
||||
await loadVerifiedRemoteRuntimeResourceBundle(
|
||||
runtimePaths,
|
||||
descriptor.architecture,
|
||||
{
|
||||
runtimeId: runtime.runtimeId,
|
||||
verificationEnvironment:
|
||||
options.verificationEnvironment ?? 'production'
|
||||
}
|
||||
)
|
||||
if (
|
||||
runtimeBundle.manifest.runtimeId !== runtime.runtimeId ||
|
||||
runtimeBundle.manifest.provider !== runtime.provider ||
|
||||
runtimeBundle.manifest.platform !== descriptor.platform ||
|
||||
runtimeBundle.manifest.runtimeVersion !==
|
||||
descriptor.remoteRuntime.version ||
|
||||
runtime.version ||
|
||||
runtimeBundle.manifest.bundleDigest !==
|
||||
descriptor.remoteRuntime.bundleDigest ||
|
||||
runtime.bundleDigest ||
|
||||
runtimeBundle.manifest.protocol.major !==
|
||||
descriptor.remoteRuntime.protocol.major ||
|
||||
runtime.protocol.major ||
|
||||
runtimeBundle.manifest.protocol.minor !==
|
||||
descriptor.remoteRuntime.protocol.minor
|
||||
runtime.protocol.minor
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package descriptor does not match its Runtime bundle'
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
rootDirectory,
|
||||
descriptor,
|
||||
@@ -765,4 +770,4 @@ export function compareSemanticVersions(
|
||||
return leftIdentifier > rightIdentifier ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
|
||||
|
||||
export type BundledRemoteRuntimeResourcePaths = {
|
||||
@@ -72,7 +72,8 @@ export function resolveBundledRemoteRuntimeResourcePaths(
|
||||
|
||||
export function getBundledRemoteRuntimeRoot(
|
||||
paths: BundledRemoteRuntimeResourcePaths,
|
||||
architecture: AgentArchitecture
|
||||
architecture: AgentArchitecture,
|
||||
runtimeId: 'opencode' | 'continue' = 'opencode'
|
||||
): string {
|
||||
return paths.runtimeRoots[architecture]
|
||||
return runtimeId === 'opencode' ? paths.runtimeRoots[architecture] : join(dirname(paths.runtimeRoots[architecture]), runtimeId)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export type PackageInstallerAgentIdentity = {
|
||||
}
|
||||
|
||||
export type PackageInstallerRuntimeIdentity = {
|
||||
runtimeId: 'opencode'
|
||||
runtimeId: 'opencode' | 'continue'
|
||||
runtimeVersion: string
|
||||
bundleDigest: string
|
||||
manifestDigest: string
|
||||
@@ -121,6 +121,7 @@ export type PackageInstallerResult = {
|
||||
archiveSha256: string
|
||||
agent: PackageInstallerAgentIdentity
|
||||
runtime: PackageInstallerRuntimeIdentity
|
||||
additionalRuntimes?: PackageInstallerRuntimeIdentity[]
|
||||
}
|
||||
|
||||
export type PackageInstallerEvent =
|
||||
@@ -166,6 +167,7 @@ type VerifiedPackage = {
|
||||
runtimeManifestDigest: string
|
||||
agent: PackageInstallerAgentIdentity
|
||||
runtime: PackageInstallerRuntimeIdentity
|
||||
additionalRuntimes?: PackageInstallerRuntimeIdentity[]
|
||||
}
|
||||
|
||||
type PreparedState = {
|
||||
@@ -178,6 +180,7 @@ type PreparedState = {
|
||||
packageSignatureSha256: string
|
||||
agent: PackageInstallerAgentIdentity
|
||||
runtime: PackageInstallerRuntimeIdentity
|
||||
additionalRuntimes?: PackageInstallerRuntimeIdentity[]
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
@@ -811,6 +814,7 @@ function assertOuterDescriptor(
|
||||
'signingKeyId',
|
||||
'agentProtocol',
|
||||
'remoteRuntime',
|
||||
...(descriptor.additionalRuntimes === undefined ? [] : ['additionalRuntimes']),
|
||||
'contentDigest',
|
||||
'files'
|
||||
],
|
||||
@@ -839,8 +843,8 @@ function assertOuterDescriptor(
|
||||
'Agent package Runtime identity'
|
||||
)
|
||||
if (
|
||||
remoteRuntime.runtimeId !== 'opencode' ||
|
||||
remoteRuntime.provider !== 'opencode' ||
|
||||
!['opencode', 'continue'].includes(String(remoteRuntime.runtimeId)) ||
|
||||
remoteRuntime.provider !== remoteRuntime.runtimeId ||
|
||||
!versionPattern.test(String(remoteRuntime.version)) ||
|
||||
!digestPattern.test(String(remoteRuntime.bundleDigest))
|
||||
) {
|
||||
@@ -848,6 +852,18 @@ function assertOuterDescriptor(
|
||||
}
|
||||
protocol(descriptor.agentProtocol, 'Agent package protocol')
|
||||
protocol(remoteRuntime.protocol, 'Agent package Runtime protocol')
|
||||
if (descriptor.additionalRuntimes !== undefined) {
|
||||
if (!Array.isArray(descriptor.additionalRuntimes) || descriptor.additionalRuntimes.length > 1) throw new Error('Invalid additional Runtimes')
|
||||
for (const extra of descriptor.additionalRuntimes) {
|
||||
if (!isRecord(extra)) throw new Error('Invalid additional Runtime')
|
||||
exactKeys(extra, ['runtimeId', 'provider', 'version', 'bundleDigest', 'protocol'], 'Additional Runtime')
|
||||
if (!['opencode', 'continue'].includes(String(extra.runtimeId)) || extra.runtimeId === remoteRuntime.runtimeId ||
|
||||
extra.provider !== extra.runtimeId || !versionPattern.test(String(extra.version)) || !digestPattern.test(String(extra.bundleDigest))) {
|
||||
throw new Error('Invalid additional Runtime identity')
|
||||
}
|
||||
protocol(extra.protocol, 'Additional Runtime protocol')
|
||||
}
|
||||
}
|
||||
const { contentDigest: _contentDigest, ...content } = descriptor
|
||||
void _contentDigest
|
||||
if (
|
||||
@@ -864,6 +880,11 @@ function assertOuterDescriptor(
|
||||
'Agent package'
|
||||
)
|
||||
const files = parseFiles(descriptor.files, 'Agent package')
|
||||
const runtimePrefixes = [remoteRuntime, ...((descriptor.additionalRuntimes as JsonRecord[] | undefined) ?? [])]
|
||||
.map(runtime => `runtime/${String(runtime.runtimeId)}/${String(runtime.bundleDigest).slice(7)}/`)
|
||||
if (files.some(file => file.path.startsWith('runtime/') && !runtimePrefixes.some(prefix => file.path.startsWith(prefix)))) {
|
||||
throw new Error('Agent package contains an undeclared Runtime')
|
||||
}
|
||||
const expected = new Set([
|
||||
...files.map((file) => file.path),
|
||||
PACKAGE_DESCRIPTOR,
|
||||
@@ -1093,8 +1114,9 @@ function assertRuntimeManifest(
|
||||
],
|
||||
'Runtime manifest'
|
||||
)
|
||||
const runtime = isRecord(lock.runtimes) && isRecord(lock.runtimes.opencode)
|
||||
? lock.runtimes.opencode
|
||||
const remote = descriptor.remoteRuntime as JsonRecord
|
||||
const runtime = isRecord(lock.runtimes) && isRecord(lock.runtimes[String(remote.runtimeId)])
|
||||
? lock.runtimes[String(remote.runtimeId)] as JsonRecord
|
||||
: undefined
|
||||
const targetKey = descriptor.platform === 'darwin' ? 'darwin-arm64' : String(descriptor.architecture)
|
||||
const target =
|
||||
@@ -1103,12 +1125,11 @@ function assertRuntimeManifest(
|
||||
isRecord(runtime.targets[targetKey])
|
||||
? runtime.targets[targetKey]
|
||||
: undefined
|
||||
const remote = descriptor.remoteRuntime as JsonRecord
|
||||
if (
|
||||
manifest.formatVersion !== 2 ||
|
||||
manifest.product !== 'GoodBuddy' ||
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.provider !== 'opencode' ||
|
||||
manifest.runtimeId !== remote.runtimeId ||
|
||||
manifest.provider !== remote.provider ||
|
||||
manifest.platform !== descriptor.platform ||
|
||||
manifest.architecture !== descriptor.architecture ||
|
||||
!versionPattern.test(String(manifest.runtimeVersion)) ||
|
||||
@@ -1195,14 +1216,14 @@ function assertRuntimeManifest(
|
||||
)
|
||||
const files = parseFiles(manifest.files, 'Runtime manifest')
|
||||
const digestDirectory = String(manifest.bundleDigest).slice('sha256:'.length)
|
||||
const prefix = `runtime/opencode/${digestDirectory}/`
|
||||
const prefix = `runtime/${String(remote.runtimeId)}/${digestDirectory}/`
|
||||
const expectedRuntimePaths = new Set([
|
||||
...files.map((file) => `${prefix}${file.path}`),
|
||||
`${prefix}manifest.json`,
|
||||
`${prefix}manifest.sig`
|
||||
])
|
||||
const actualRuntimePaths = [...packageFiles.keys()].filter((path) =>
|
||||
path.startsWith('runtime/')
|
||||
path.startsWith(`runtime/${String(remote.runtimeId)}/`)
|
||||
)
|
||||
if (
|
||||
actualRuntimePaths.length !== expectedRuntimePaths.size ||
|
||||
@@ -1214,7 +1235,7 @@ function assertRuntimeManifest(
|
||||
if (!safeManifestPath(file.path)) {
|
||||
throw new Error(`Runtime manifest path is invalid: ${file.path}`)
|
||||
}
|
||||
const expectedMode = file.path === 'bin/opencode' ? '0755' : '0644'
|
||||
const expectedMode = file.path === entrypoint.path ? '0755' : '0644'
|
||||
const outer = packageFiles.get(`${prefix}${file.path}`)
|
||||
if (
|
||||
file.mode !== expectedMode ||
|
||||
@@ -1232,20 +1253,20 @@ function assertRuntimeManifest(
|
||||
'Runtime manifest'
|
||||
)
|
||||
const openCodeLicense = runtimeLicenses.find(
|
||||
(license) => license.package === 'opencode-ai'
|
||||
(license) => license.package === (remote.runtimeId === 'continue' ? '@continuedev/cli' : 'opencode-ai')
|
||||
)
|
||||
if (
|
||||
openCodeLicense?.version !== manifest.runtimeVersion ||
|
||||
openCodeLicense?.spdx !== 'MIT' ||
|
||||
openCodeLicense?.path !== 'licenses/opencode-MIT.txt'
|
||||
openCodeLicense?.spdx !== (remote.runtimeId === 'continue' ? 'Apache-2.0' : 'MIT') ||
|
||||
openCodeLicense?.path !== (remote.runtimeId === 'continue' ? 'licenses/continue-Apache-2.0.txt' : 'licenses/opencode-MIT.txt')
|
||||
) {
|
||||
throw new Error('Runtime license declaration is invalid')
|
||||
}
|
||||
if (
|
||||
entrypoint.path !== 'bin/opencode' ||
|
||||
entrypoint.path !== (remote.runtimeId === 'continue' ? 'lib/continue/dist/cn.js' : 'bin/opencode') ||
|
||||
!files.some(
|
||||
(file) =>
|
||||
file.path === 'bin/opencode' &&
|
||||
file.path === entrypoint.path &&
|
||||
file.sha256 === entrypoint.sha256
|
||||
)
|
||||
) {
|
||||
@@ -1299,12 +1320,13 @@ function elfArchitecture(bytes: Buffer, platform: Platform): Architecture | unde
|
||||
function assertExtractedArchitectures(
|
||||
preparedRoot: string,
|
||||
architecture: Architecture,
|
||||
platform: Platform
|
||||
platform: Platform,
|
||||
runtimeId: 'opencode' | 'continue'
|
||||
): void {
|
||||
for (const path of [
|
||||
join(preparedRoot, 'agent', 'node'),
|
||||
...nativeKoffiPaths(platform, architecture).map(path => join(preparedRoot, 'agent', path)),
|
||||
join(preparedRoot, 'runtime', 'bin', 'opencode')
|
||||
...(runtimeId === 'opencode' ? [join(preparedRoot, 'runtime', 'bin', 'opencode')] : [])
|
||||
]) {
|
||||
if (
|
||||
elfArchitecture(readFileHeader(path, 64), platform) !== architecture
|
||||
@@ -1460,13 +1482,14 @@ function verifyArchive(
|
||||
const runtimeDigest = String(
|
||||
(descriptor.remoteRuntime as JsonRecord).bundleDigest
|
||||
).slice('sha256:'.length)
|
||||
const runtimeId = (descriptor.remoteRuntime as JsonRecord).runtimeId as 'opencode' | 'continue'
|
||||
const runtimeManifestBytes = readEntry(
|
||||
handle,
|
||||
entries,
|
||||
`runtime/opencode/${runtimeDigest}/manifest.json`
|
||||
`runtime/${runtimeId}/${runtimeDigest}/manifest.json`
|
||||
)
|
||||
assertDeclaredMetadata(
|
||||
`runtime/opencode/${runtimeDigest}/manifest.json`,
|
||||
`runtime/${runtimeId}/${runtimeDigest}/manifest.json`,
|
||||
runtimeManifestBytes,
|
||||
declaredMap
|
||||
)
|
||||
@@ -1481,7 +1504,7 @@ function verifyArchive(
|
||||
declaredMap
|
||||
)
|
||||
const runtimeSignaturePath =
|
||||
`runtime/opencode/${runtimeDigest}/manifest.sig`
|
||||
`runtime/${runtimeId}/${runtimeDigest}/manifest.sig`
|
||||
const runtimeSignatureBytes = readEntry(
|
||||
handle,
|
||||
entries,
|
||||
@@ -1503,6 +1526,16 @@ function verifyArchive(
|
||||
descriptor,
|
||||
declaredMap
|
||||
)
|
||||
const additionalRuntimes = (descriptor.additionalRuntimes as JsonRecord[] | undefined)?.map(remote => {
|
||||
const prefix = `runtime/${String(remote.runtimeId)}/${String(remote.bundleDigest).slice(7)}/`
|
||||
const bytes = readEntry(handle, entries, `${prefix}manifest.json`)
|
||||
const signature = readEntry(handle, entries, `${prefix}manifest.sig`)
|
||||
assertDeclaredMetadata(`${prefix}manifest.json`, bytes, declaredMap)
|
||||
assertDeclaredMetadata(`${prefix}manifest.sig`, signature, declaredMap)
|
||||
const verified = assertRuntimeManifest(bytes, signature, registry,
|
||||
parseCanonicalJson(remoteRuntimeLockBytes, 'Remote Runtime lock'), { ...descriptor, remoteRuntime: remote }, declaredMap)
|
||||
return runtimeIdentity(verified, descriptor)
|
||||
})
|
||||
if (!sameSnapshot(snapshot, fstatSync(handle))) {
|
||||
throw new Error('Agent package archive changed during verification')
|
||||
}
|
||||
@@ -1522,6 +1555,7 @@ function verifyArchive(
|
||||
runtimeManifest: runtime.manifest,
|
||||
agentManifestSha256,
|
||||
runtimeManifestDigest: runtime.manifestDigest,
|
||||
...(additionalRuntimes ? { additionalRuntimes } : {}),
|
||||
agent: {
|
||||
installationId: `agent-${agentManifestSha256}`,
|
||||
agentVersion: String(agentManifest.agentVersion),
|
||||
@@ -1533,7 +1567,7 @@ function verifyArchive(
|
||||
supervisor: 'detached-on-demand'
|
||||
},
|
||||
runtime: {
|
||||
runtimeId: 'opencode',
|
||||
runtimeId,
|
||||
runtimeVersion: String(runtime.manifest.runtimeVersion),
|
||||
bundleDigest: String(runtime.manifest.bundleDigest),
|
||||
manifestDigest: runtime.manifestDigest,
|
||||
@@ -1646,7 +1680,8 @@ function persistPreparedState(
|
||||
packageSignatureSha256:
|
||||
sha256(verified.packageSignatureBytes),
|
||||
agent: verified.agent,
|
||||
runtime: verified.runtime
|
||||
runtime: verified.runtime,
|
||||
...(verified.additionalRuntimes ? { additionalRuntimes: verified.additionalRuntimes } : {})
|
||||
}
|
||||
const bytes = prettyBytes(state)
|
||||
if (bytes.length > MAXIMUM_METADATA_BYTES) {
|
||||
@@ -1728,7 +1763,10 @@ function extractPayload(
|
||||
mkdirSync(join(temporary, 'agent'), { recursive: true, mode: 0o700 })
|
||||
mkdirSync(join(temporary, 'runtime'), { recursive: true, mode: 0o700 })
|
||||
const runtimePrefix =
|
||||
`runtime/opencode/${verified.runtime.bundleDigest.slice('sha256:'.length)}/`
|
||||
`runtime/${verified.runtime.runtimeId}/${verified.runtime.bundleDigest.slice('sha256:'.length)}/`
|
||||
const extraPrefixes = (verified.additionalRuntimes ?? []).map(runtime => ({
|
||||
prefix: `runtime/${runtime.runtimeId}/${runtime.bundleDigest.slice(7)}/`, directory: `runtime-${runtime.runtimeId}`
|
||||
}))
|
||||
const declared = new Map(
|
||||
parseFiles(verified.descriptor.files, 'Agent package')
|
||||
.map((file) => [file.path, file])
|
||||
@@ -1757,6 +1795,9 @@ function extractPayload(
|
||||
...name.slice(runtimePrefix.length).split('/')
|
||||
)
|
||||
}
|
||||
for (const extra of extraPrefixes) {
|
||||
if (name.startsWith(extra.prefix)) target = join(temporary, extra.directory, ...name.slice(extra.prefix.length).split('/'))
|
||||
}
|
||||
if (target !== undefined) {
|
||||
const actual = streamEntry(handle, entry, target)
|
||||
const expected = declared.get(name)
|
||||
@@ -1777,7 +1818,11 @@ function extractPayload(
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
assertExtractedArchitectures(temporary, verified.agent.architecture, verified.agent.platform)
|
||||
assertExtractedArchitectures(temporary, verified.agent.architecture, verified.agent.platform, verified.runtime.runtimeId)
|
||||
if (verified.additionalRuntimes?.some(runtime => runtime.runtimeId === 'opencode') &&
|
||||
elfArchitecture(readFileHeader(join(temporary, 'runtime-opencode/bin/opencode'), 64), verified.agent.platform) !== verified.agent.architecture) {
|
||||
throw new Error('Additional OpenCode executable architecture mismatch')
|
||||
}
|
||||
chmodSync(temporary, 0o700)
|
||||
renameSync(temporary, destinationRoot)
|
||||
} catch (error) {
|
||||
@@ -1812,7 +1857,8 @@ export function preparePackage(options: InstallerOptions): PackageInstallerResul
|
||||
status: 'prepared',
|
||||
archiveSha256: state.archiveSha256,
|
||||
agent: state.agent,
|
||||
runtime: state.runtime
|
||||
runtime: state.runtime,
|
||||
...(state.additionalRuntimes ? { additionalRuntimes: state.additionalRuntimes } : {})
|
||||
}
|
||||
options.emit?.(result)
|
||||
return result
|
||||
@@ -1843,7 +1889,8 @@ export function preparePackage(options: InstallerOptions): PackageInstallerResul
|
||||
status: 'prepared',
|
||||
archiveSha256: verified.archiveSha256,
|
||||
agent: verified.agent,
|
||||
runtime: verified.runtime
|
||||
runtime: verified.runtime,
|
||||
...(verified.additionalRuntimes ? { additionalRuntimes: verified.additionalRuntimes } : {})
|
||||
}
|
||||
options.emit?.(result)
|
||||
return result
|
||||
@@ -2070,6 +2117,7 @@ function readPreparedState(path: string): PreparedState {
|
||||
'packageSignatureSha256',
|
||||
'agent',
|
||||
'runtime'
|
||||
,...(value.additionalRuntimes === undefined ? [] : ['additionalRuntimes'])
|
||||
],
|
||||
'Prepared package state'
|
||||
)
|
||||
@@ -2097,6 +2145,11 @@ function readPreparedState(path: string): PreparedState {
|
||||
throw new Error('Prepared package state is invalid')
|
||||
}
|
||||
const agent = value.agent
|
||||
if (value.additionalRuntimes !== undefined && (!Array.isArray(value.additionalRuntimes) || value.additionalRuntimes.length > 1 ||
|
||||
value.additionalRuntimes.some(extra => !isRecord(extra) || !['opencode', 'continue'].includes(String(extra.runtimeId)) ||
|
||||
extra.runtimeId === (value.runtime as JsonRecord).runtimeId || !digestPattern.test(String(extra.bundleDigest))))) {
|
||||
throw new Error('Invalid prepared additional Runtimes')
|
||||
}
|
||||
const runtime = value.runtime
|
||||
exactKeys(
|
||||
agent,
|
||||
@@ -2141,7 +2194,7 @@ function readPreparedState(path: string): PreparedState {
|
||||
!isSupportedTarget(agent.platform, agent.architecture) ||
|
||||
!['x64', 'arm64'].includes(String(agent.architecture)) ||
|
||||
agent.supervisor !== 'detached-on-demand' ||
|
||||
runtime.runtimeId !== 'opencode' ||
|
||||
!['opencode', 'continue'].includes(String(runtime.runtimeId)) ||
|
||||
!versionPattern.test(String(runtime.runtimeVersion)) ||
|
||||
!digestPattern.test(String(runtime.bundleDigest)) ||
|
||||
!digestPattern.test(String(runtime.manifestDigest)) ||
|
||||
@@ -2155,6 +2208,7 @@ function readPreparedState(path: string): PreparedState {
|
||||
return {
|
||||
formatVersion: 1,
|
||||
archiveSha256: String(value.archiveSha256),
|
||||
...(value.additionalRuntimes ? { additionalRuntimes: value.additionalRuntimes as PackageInstallerRuntimeIdentity[] } : {}),
|
||||
releaseKeyRegistrySha256:
|
||||
String(value.releaseKeyRegistrySha256),
|
||||
agentRuntimeLockSha256:
|
||||
@@ -2176,7 +2230,7 @@ function readPreparedState(path: string): PreparedState {
|
||||
supervisor: 'detached-on-demand'
|
||||
},
|
||||
runtime: {
|
||||
runtimeId: 'opencode',
|
||||
runtimeId: runtime.runtimeId as 'opencode' | 'continue',
|
||||
runtimeVersion: String(runtime.runtimeVersion),
|
||||
bundleDigest: String(runtime.bundleDigest),
|
||||
manifestDigest: String(runtime.manifestDigest),
|
||||
@@ -2230,6 +2284,16 @@ function readBoundedRegularFile(
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeIdentity(runtime: { manifest: JsonRecord; manifestDigest: string }, descriptor: JsonRecord): PackageInstallerRuntimeIdentity {
|
||||
return {
|
||||
runtimeId: runtime.manifest.runtimeId as 'opencode' | 'continue',
|
||||
runtimeVersion: String(runtime.manifest.runtimeVersion), bundleDigest: String(runtime.manifest.bundleDigest),
|
||||
manifestDigest: runtime.manifestDigest, runtimeAdapterDigest: String(runtime.manifest.adapterDigest),
|
||||
acpCapabilitiesDigest: String(runtime.manifest.acpCapabilitiesDigest), platform: descriptor.platform as Platform,
|
||||
architecture: descriptor.architecture as Architecture, protocol: protocol(runtime.manifest.protocol, 'Runtime protocol')
|
||||
}
|
||||
}
|
||||
|
||||
function assertPreparedIdentity(
|
||||
agentRoot: string,
|
||||
runtimeRoot: string,
|
||||
@@ -2238,12 +2302,17 @@ function assertPreparedIdentity(
|
||||
agentRuntimeLockBytes: Buffer,
|
||||
remoteRuntimeLockBytes: Buffer,
|
||||
descriptorBytes: Buffer,
|
||||
packageSignatureBytes: Buffer
|
||||
packageSignatureBytes: Buffer,
|
||||
selectedRuntime = state.runtime
|
||||
): void {
|
||||
const descriptor = parseCanonicalJson(
|
||||
descriptorBytes,
|
||||
'Prepared Agent package descriptor'
|
||||
)
|
||||
const packagedRuntimes = [descriptor.remoteRuntime, ...((descriptor.additionalRuntimes as JsonRecord[] | undefined) ?? [])] as JsonRecord[]
|
||||
if (packagedRuntimes.length !== 1 + (state.additionalRuntimes?.length ?? 0)) throw new Error('Prepared Runtime inventory differs from package')
|
||||
const remoteRuntime = packagedRuntimes.find(runtime => runtime.runtimeId === selectedRuntime.runtimeId)
|
||||
if (!remoteRuntime) throw new Error('Prepared Runtime is not declared in package')
|
||||
const declared = parseFiles(
|
||||
descriptor.files,
|
||||
'Prepared Agent package'
|
||||
@@ -2323,7 +2392,7 @@ function assertPreparedIdentity(
|
||||
remoteRuntimeLockBytes,
|
||||
'Prepared Remote Runtime lock'
|
||||
),
|
||||
descriptor,
|
||||
{ ...descriptor, remoteRuntime },
|
||||
packageFiles
|
||||
)
|
||||
const agentManifestSha256 = sha256(agentManifestBytes)
|
||||
@@ -2339,7 +2408,7 @@ function assertPreparedIdentity(
|
||||
supervisor: 'detached-on-demand'
|
||||
}
|
||||
const expectedRuntime: PackageInstallerRuntimeIdentity = {
|
||||
runtimeId: 'opencode',
|
||||
runtimeId: runtime.manifest.runtimeId as 'opencode' | 'continue',
|
||||
runtimeVersion: String(runtime.manifest.runtimeVersion),
|
||||
bundleDigest: String(runtime.manifest.bundleDigest),
|
||||
manifestDigest: runtime.manifestDigest,
|
||||
@@ -2356,7 +2425,7 @@ function assertPreparedIdentity(
|
||||
}
|
||||
if (
|
||||
canonicalJson(state.agent) !== canonicalJson(expectedAgent) ||
|
||||
canonicalJson(state.runtime) !== canonicalJson(expectedRuntime)
|
||||
canonicalJson(selectedRuntime) !== canonicalJson(expectedRuntime)
|
||||
) {
|
||||
throw new Error(
|
||||
'Prepared package identity does not match the authenticated archive'
|
||||
@@ -2473,6 +2542,15 @@ export function commitPackage(options: InstallerOptions): PackageInstallerResult
|
||||
join(managedRoot, 'runtimes', 'release-keys.json'),
|
||||
join(managedRoot, 'runtimes', 'remote-runtime-lock.json')
|
||||
] as const
|
||||
const extraPublications = (state.additionalRuntimes ?? []).map(runtime => {
|
||||
ensureOwnedManagedHierarchy(home, runtime.runtimeId)
|
||||
const source = join(payloadRoot, `runtime-${runtime.runtimeId}`)
|
||||
const destination = join(managedRoot, 'runtimes', runtime.runtimeId, runtime.bundleDigest.slice(7))
|
||||
assertPreparedIdentity(agentSourceExists ? agentSource : agentDestination,
|
||||
existsSync(source) ? source : destination, state, releaseKeyRegistryBytes, agentRuntimeLockBytes,
|
||||
remoteRuntimeLockBytes, descriptorBytes, packageSignatureBytes, runtime)
|
||||
return { source, destination }
|
||||
})
|
||||
if (!agentSourceExists || !runtimeSourceExists) {
|
||||
assertPreparedIdentity(
|
||||
agentSourceExists ? agentSource : agentDestination,
|
||||
@@ -2507,6 +2585,9 @@ export function commitPackage(options: InstallerOptions): PackageInstallerResult
|
||||
)
|
||||
)
|
||||
}
|
||||
for (const extra of extraPublications) {
|
||||
if (existsSync(extra.source)) published.push(publishDirectory(extra.source, extra.destination, inputs.operationRoot))
|
||||
}
|
||||
progress('publishing-metadata')
|
||||
writePrivateFileAtomic(
|
||||
metadataPaths[0],
|
||||
@@ -2529,7 +2610,8 @@ export function commitPackage(options: InstallerOptions): PackageInstallerResult
|
||||
status: 'committed',
|
||||
archiveSha256: state.archiveSha256,
|
||||
agent: state.agent,
|
||||
runtime: state.runtime
|
||||
runtime: state.runtime,
|
||||
...(state.additionalRuntimes ? { additionalRuntimes: state.additionalRuntimes } : {})
|
||||
}
|
||||
options.emit?.(result)
|
||||
return result
|
||||
|
||||
@@ -277,19 +277,25 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('rejects non-OpenCode providers before acquiring remote resources', async () => {
|
||||
it('rejects unsupported providers before acquiring remote resources', async () => {
|
||||
const fixture = harness({
|
||||
selection: { provider: 'continue' }
|
||||
selection: { provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
createManagedRemoteAcpRuntime(fixture.options)
|
||||
).rejects.toThrow(/OpenCode Runtime/iu)
|
||||
).rejects.toThrow(/OpenCode or Continue/iu)
|
||||
expect(
|
||||
fixture.options.agentServices.connectionManager.acquire
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an installed Runtime that differs from the selected Continue provider', async () => {
|
||||
const fixture = harness({ selection: { provider: 'continue' } })
|
||||
await expect(createManagedRemoteAcpRuntime(fixture.options)).rejects.toThrow(/selected provider/iu)
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('binds the current Agent and Runtime and owns its connection lease', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
@@ -307,6 +313,7 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
expect(
|
||||
fixture.options.runtimeInstallationManager.activateInstalled
|
||||
).toHaveBeenCalledWith('host-1', {
|
||||
runtimeId: 'opencode',
|
||||
agentInstallationId: 'agent-1'
|
||||
})
|
||||
expect(
|
||||
|
||||
@@ -43,7 +43,7 @@ type ManagedRemoteTransportIdentity = {
|
||||
}
|
||||
|
||||
type ManagedRemoteRuntimeIdentity = {
|
||||
runtimeId: 'opencode'
|
||||
runtimeId: 'opencode' | 'continue'
|
||||
runtimeVersion: string
|
||||
bundleDigest: string
|
||||
runtimeAdapterDigest: string
|
||||
@@ -80,9 +80,9 @@ export async function createManagedRemoteAcpRuntime(
|
||||
options: ManagedRemoteAcpRuntimeOptions
|
||||
): Promise<AgentRuntime> {
|
||||
const descriptor = options.executionSpace
|
||||
if (options.selection.provider !== 'opencode') {
|
||||
if (options.selection.provider !== 'opencode' && options.selection.provider !== 'continue') {
|
||||
throw new Error(
|
||||
'Managed remote execution requires the OpenCode Runtime'
|
||||
'Managed remote execution requires OpenCode or Continue'
|
||||
)
|
||||
}
|
||||
const workspaceBinding = workspaceProjectBinding(descriptor)
|
||||
@@ -114,8 +114,11 @@ export async function createManagedRemoteAcpRuntime(
|
||||
const runtimeInstallation =
|
||||
await options.runtimeInstallationManager.activateInstalled(
|
||||
descriptor.hostId,
|
||||
{ agentInstallationId: agent.installationId }
|
||||
{ agentInstallationId: agent.installationId, runtimeId: options.selection.provider }
|
||||
)
|
||||
if (runtimeInstallation.runtimeId !== options.selection.provider) {
|
||||
throw new Error('Installed Runtime does not match the selected provider')
|
||||
}
|
||||
const capabilities = await activeConnection.refreshCapabilities()
|
||||
assertConnectionMatches(workspaceBinding, agent, activeConnection)
|
||||
const runtime = runtimeIdentityFromCapabilities(
|
||||
@@ -137,8 +140,8 @@ export async function createManagedRemoteAcpRuntime(
|
||||
transport: ManagedRemoteTransportIdentity
|
||||
): AcpRemoteRuntime => {
|
||||
const remoteOptions: AcpRemoteRuntimeOptions = {
|
||||
runtimeId: 'opencode',
|
||||
label: 'OpenCode(远程托管)',
|
||||
runtimeId: runtime.runtimeId,
|
||||
label: `${runtime.runtimeId === 'continue' ? 'Continue' : 'OpenCode'}(远程托管)`,
|
||||
workspacePath: descriptor.remoteRootPath,
|
||||
identity: {
|
||||
controllerId,
|
||||
@@ -180,7 +183,7 @@ export async function createManagedRemoteAcpRuntime(
|
||||
...(options.usage
|
||||
? {
|
||||
usage: {
|
||||
runtime: 'opencode',
|
||||
runtime: runtime.runtimeId,
|
||||
provider: options.usage.provider,
|
||||
model: options.usage.model
|
||||
}
|
||||
@@ -296,7 +299,7 @@ class ManagedRemoteRuntimeResources {
|
||||
connection: this.connection,
|
||||
openIdentity: {
|
||||
bindingId,
|
||||
runtimeId: 'opencode',
|
||||
runtimeId: this.runtime.runtimeId,
|
||||
runtimeBundleDigest: this.runtime.bundleDigest,
|
||||
workspaceIdentity: this.workspaceIdentity
|
||||
}
|
||||
@@ -429,7 +432,7 @@ class ManagedRemoteAcpRuntime implements AgentRuntime {
|
||||
readonly requiresToolApproval = false
|
||||
readonly supportsScopedDataTools = false
|
||||
readonly capability = 'chat' as const
|
||||
readonly runtimeId = 'opencode' as const
|
||||
get runtimeId(): 'opencode' | 'continue' { return this.remote.runtimeId as 'opencode' | 'continue' }
|
||||
#disposePromise?: Promise<void>
|
||||
#transitionTail: Promise<void> = Promise.resolve()
|
||||
#activeRuns = 0
|
||||
@@ -660,7 +663,7 @@ function runtimeIdentityFromCapabilities(
|
||||
>['capabilities']
|
||||
): ManagedRemoteRuntimeIdentity {
|
||||
if (
|
||||
installation.runtimeId !== 'opencode' ||
|
||||
(installation.runtimeId !== 'opencode' && installation.runtimeId !== 'continue') ||
|
||||
(installation.platform !== 'linux' && !(installation.platform === 'darwin' && installation.architecture === 'arm64'))
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -668,7 +671,7 @@ function runtimeIdentityFromCapabilities(
|
||||
)
|
||||
}
|
||||
const runtime: ManagedRemoteRuntimeIdentity = {
|
||||
runtimeId: 'opencode',
|
||||
runtimeId: installation.runtimeId,
|
||||
runtimeVersion: installation.runtimeVersion,
|
||||
bundleDigest: installation.bundleDigest,
|
||||
runtimeAdapterDigest: installation.runtimeAdapterDigest,
|
||||
@@ -718,7 +721,7 @@ function assertRuntimeAdvertised(
|
||||
(entry) => entry.name === RUNTIME_ACP_CAPABILITY.name
|
||||
)
|
||||
const advertised = capabilities.runtimes.filter(
|
||||
(entry) => entry.runtimeId === 'opencode'
|
||||
(entry) => entry.runtimeId === runtime.runtimeId
|
||||
)
|
||||
if (
|
||||
capability === undefined ||
|
||||
|
||||
@@ -93,7 +93,7 @@ export class ManagedRemoteExecutionServices {
|
||||
)
|
||||
).installationId
|
||||
signal.throwIfAborted()
|
||||
if (runtimeId !== 'opencode') {
|
||||
if (runtimeId !== 'opencode' && runtimeId !== 'continue') {
|
||||
throw new Error(
|
||||
'Remote Runtime identity is unsupported'
|
||||
)
|
||||
|
||||
@@ -186,6 +186,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
|
||||
expect(test.calls).toEqual(['model', 'activate', 'refresh'])
|
||||
expect(test.activateInstalled).toHaveBeenCalledWith('host-1', {
|
||||
runtimeId: 'opencode',
|
||||
agentInstallationId,
|
||||
signal: test.input.signal
|
||||
})
|
||||
@@ -209,7 +210,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
expect(() => lease.assertCurrent()).toThrow(/not ready/iu)
|
||||
})
|
||||
|
||||
it('rejects non-OpenCode selections before installation', async () => {
|
||||
it('rejects unsupported selections before installation', async () => {
|
||||
const test = harness({
|
||||
selection: {
|
||||
provider: 'model',
|
||||
@@ -218,12 +219,28 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
})
|
||||
|
||||
await expect(test.validator.validate(test.input)).rejects.toThrow(
|
||||
/OpenCode Runtime/iu
|
||||
/OpenCode or Continue/iu
|
||||
)
|
||||
expect(test.activateInstalled).not.toHaveBeenCalled()
|
||||
expect(test.refreshCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates Continue against its own installed bundle and capability', async () => {
|
||||
const refreshed = advertisedCapabilities()
|
||||
refreshed.runtimes[0]!.runtimeId = 'continue'
|
||||
const test = harness({
|
||||
selection: { provider: 'continue' },
|
||||
installed: { ...installation, runtimeId: 'continue' },
|
||||
refreshed
|
||||
})
|
||||
const lease = await test.validator.validate(test.input)
|
||||
expect(test.activateInstalled).toHaveBeenCalledWith('host-1', {
|
||||
runtimeId: 'continue', agentInstallationId, signal: test.input.signal
|
||||
})
|
||||
expect(() => lease.assertCurrent()).not.toThrow()
|
||||
lease.release()
|
||||
})
|
||||
|
||||
it('requires the installed Runtime architecture to exactly match the Agent', async () => {
|
||||
const test = harness({ architecture: 'arm64' })
|
||||
|
||||
|
||||
@@ -46,10 +46,10 @@ export class ManagedRemoteProjectRuntimeValidator
|
||||
): Promise<RemoteProjectRuntimeValidationLease> {
|
||||
input.signal.throwIfAborted()
|
||||
if (
|
||||
input.selection.provider !== 'opencode'
|
||||
input.selection.provider !== 'opencode' && input.selection.provider !== 'continue'
|
||||
) {
|
||||
throw new Error(
|
||||
'Managed remote projects require the OpenCode Runtime'
|
||||
'Managed remote projects require OpenCode or Continue'
|
||||
)
|
||||
}
|
||||
assertInputIdentity(input)
|
||||
@@ -72,7 +72,8 @@ export class ManagedRemoteProjectRuntimeValidator
|
||||
input.host.hostId,
|
||||
{
|
||||
signal: input.signal,
|
||||
agentInstallationId: input.agent.installationId
|
||||
agentInstallationId: input.agent.installationId,
|
||||
runtimeId: input.selection.provider
|
||||
}
|
||||
)
|
||||
input.signal.throwIfAborted()
|
||||
@@ -164,7 +165,7 @@ function assertInstallationIdentity(
|
||||
installation: RemoteRuntimeInstallationIdentity
|
||||
): void {
|
||||
if (
|
||||
installation.runtimeId !== 'opencode' ||
|
||||
installation.runtimeId !== input.selection.provider ||
|
||||
installation.platform !== input.agent.platform ||
|
||||
installation.architecture !== input.agent.architecture
|
||||
) {
|
||||
@@ -186,7 +187,7 @@ function assertRuntimeCapability(
|
||||
capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME
|
||||
)
|
||||
const runtimes = capabilities.runtimes.filter(
|
||||
(runtime) => runtime.runtimeId === 'opencode'
|
||||
(runtime) => runtime.runtimeId === installation.runtimeId
|
||||
)
|
||||
if (
|
||||
acp === undefined ||
|
||||
|
||||
@@ -594,6 +594,24 @@ describe('ProtocolRemoteRuntimeChannel', () => {
|
||||
await fixture.channel.close()
|
||||
})
|
||||
|
||||
it('allows cold native initialization beyond the ordinary control timeout without resending', async () => {
|
||||
const fixture = await openChannel()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
fixture.client.responder = async (method, params) => {
|
||||
if (method === 'runtime/startPrompt') await new Promise(resolve => setTimeout(resolve, 20_000))
|
||||
return defaultResponse(method, params)
|
||||
}
|
||||
const started = fixture.channel.startOwnedPrompt({
|
||||
bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'hello' }]
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await expect(started).resolves.toMatchObject({ sessionId: 'session-owned-1' })
|
||||
expect(fixture.client.requests.filter(request => request.method === 'runtime/startPrompt')).toHaveLength(1)
|
||||
} finally { vi.useRealTimers(); await fixture.channel.close() }
|
||||
})
|
||||
|
||||
it('attaches the exact operation when a prompt start response is lost', async () => {
|
||||
const fixture = await openChannel()
|
||||
const deadlineAt = new Date(Date.now() + 10_000).toISOString()
|
||||
|
||||
@@ -1116,7 +1116,10 @@ export class ProtocolRemoteRuntimeChannel
|
||||
this.#state.client,
|
||||
method,
|
||||
params,
|
||||
this.#state.controlTimeoutMs,
|
||||
// Cold OpenCode ACP initialization on the shared Host exceeds 15 seconds.
|
||||
method === 'runtime/startPrompt'
|
||||
? Math.max(this.#state.controlTimeoutMs, 30_000)
|
||||
: this.#state.controlTimeoutMs,
|
||||
this.#lifetime.signal
|
||||
)
|
||||
this.#assertCurrent()
|
||||
|
||||
@@ -389,21 +389,23 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
signal.throwIfAborted()
|
||||
|
||||
emit(method, 'installing-runtime')
|
||||
for (const runtime of [identity.runtime, ...(identity.additionalRuntimes ?? [])]) {
|
||||
assertAgentCommandSucceeded(
|
||||
await lease.runAgentRuntimeAction(
|
||||
installationId,
|
||||
{
|
||||
kind: 'runtime-activate',
|
||||
runtimeId: identity.runtime.runtimeId,
|
||||
bundleDigest: identity.runtime.bundleDigest,
|
||||
architecture: identity.runtime.architecture,
|
||||
runtimeId: runtime.runtimeId,
|
||||
bundleDigest: runtime.bundleDigest,
|
||||
architecture: runtime.architecture,
|
||||
forceVerification: true
|
||||
},
|
||||
signal
|
||||
),
|
||||
'OpenCode Runtime 激活'
|
||||
`${runtime.runtimeId} Runtime 激活`
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
|
||||
async #prepareUploadedArchive(options: {
|
||||
@@ -693,6 +695,14 @@ function assertPackageIdentity(
|
||||
identity: SshRemotePackageIdentity,
|
||||
candidate: VerifiedRemoteAgentInstallCandidate
|
||||
): void {
|
||||
const extras = identity.additionalRuntimes ?? []
|
||||
const expected = candidate.additionalRuntimes ?? []
|
||||
if (extras.length !== expected.length || extras.some((runtime, index) => {
|
||||
const item = expected[index]!
|
||||
return runtime.runtimeId !== item.runtimeId || runtime.runtimeVersion !== item.version ||
|
||||
runtime.bundleDigest !== item.bundleDigest || runtime.platform !== candidate.platform ||
|
||||
runtime.architecture !== candidate.architecture || !isDeepStrictEqual(runtime.protocol, item.protocol)
|
||||
})) throw new Error('Additional Runtime identity does not match the signed package')
|
||||
if (
|
||||
identity.archiveSha256 !== candidate.sha256 ||
|
||||
identity.agent.agentVersion !== candidate.version ||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { remoteHostTargetIdentityKey } from './remote-host-target-identity'
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
const RUNTIME_REGISTRY_PATH =
|
||||
'.goodbuddy/runtimes/registry.json'
|
||||
const OPENCODE_ROOT = '.goodbuddy/runtimes/opencode'
|
||||
|
||||
export type RemoteRuntimeInstallationPhase =
|
||||
| 'inspecting-host'
|
||||
@@ -45,6 +44,7 @@ export interface RemoteRuntimeInstallationTargetResolver {
|
||||
}
|
||||
|
||||
export type RemoteRuntimeActivationRequestOptions = {
|
||||
runtimeId?: 'opencode' | 'continue'
|
||||
signal?: AbortSignal
|
||||
onProgress?: (phase: RemoteRuntimeInstallationPhase) => void
|
||||
agentInstallationId?: VerifiedAgentInstallationId
|
||||
@@ -117,7 +117,8 @@ export class RemoteRuntimeInstallationManager {
|
||||
this.#coordinator.assertAvailable()
|
||||
options.signal?.throwIfAborted()
|
||||
emitOne(options.onProgress, 'inspecting-host')
|
||||
const agentKey = options.agentInstallationId ?? ''
|
||||
const runtimeId = options.runtimeId ?? 'opencode'
|
||||
const agentKey = `${options.agentInstallationId ?? ''}\0${runtimeId}`
|
||||
const earlyCached =
|
||||
this.#coordinator.cachedForHost(hostId, agentKey)
|
||||
if (earlyCached !== undefined) {
|
||||
@@ -154,6 +155,7 @@ export class RemoteRuntimeInstallationManager {
|
||||
this.#activateRegistered(
|
||||
target,
|
||||
options.agentInstallationId,
|
||||
runtimeId,
|
||||
signal,
|
||||
progress
|
||||
)
|
||||
@@ -171,6 +173,7 @@ export class RemoteRuntimeInstallationManager {
|
||||
async #activateRegistered(
|
||||
target: SshConnectionPoolTarget,
|
||||
agentInstallationId: VerifiedAgentInstallationId | undefined,
|
||||
runtimeId: 'opencode' | 'continue',
|
||||
signal: AbortSignal,
|
||||
progress: (phase: RemoteRuntimeInstallationPhase) => void
|
||||
): Promise<RemoteRuntimeInstallationIdentity> {
|
||||
@@ -182,7 +185,7 @@ export class RemoteRuntimeInstallationManager {
|
||||
const probe = await lease.runAgentBootstrapProbe(signal)
|
||||
if (!probe.ready) {
|
||||
throw new RemoteRuntimeInstallationError(
|
||||
`Remote host cannot activate OpenCode Runtime: ${probe.reason}`,
|
||||
`Remote host cannot activate ${runtimeId} Runtime: ${probe.reason}`,
|
||||
'incompatible',
|
||||
probe.reason
|
||||
)
|
||||
@@ -222,12 +225,12 @@ export class RemoteRuntimeInstallationManager {
|
||||
)
|
||||
const current = registry.current.find(
|
||||
(entry) =>
|
||||
entry.runtimeId === 'opencode' &&
|
||||
entry.runtimeId === runtimeId &&
|
||||
entry.architecture === probe.architecture
|
||||
)
|
||||
if (current === undefined) {
|
||||
throw new RemoteRuntimeInstallationError(
|
||||
'The Host has no current OpenCode Runtime for this architecture',
|
||||
`The Host has no current ${runtimeId} Runtime for this architecture`,
|
||||
'incompatible'
|
||||
)
|
||||
}
|
||||
@@ -236,7 +239,7 @@ export class RemoteRuntimeInstallationManager {
|
||||
current.runtimeAdapterDigest
|
||||
if (runtimeAdapterDigest === undefined) {
|
||||
const manifestPath =
|
||||
`${OPENCODE_ROOT}/${digestDirectoryName(
|
||||
`.goodbuddy/runtimes/${runtimeId}/${digestDirectoryName(
|
||||
current.bundleDigest
|
||||
)}/manifest.json`
|
||||
const manifest =
|
||||
@@ -288,7 +291,7 @@ export class RemoteRuntimeInstallationManager {
|
||||
}
|
||||
if (isMissingPathError(error)) {
|
||||
throw new RemoteRuntimeInstallationError(
|
||||
'The Host has no current OpenCode Runtime',
|
||||
`The Host has no current ${runtimeId} Runtime`,
|
||||
'incompatible',
|
||||
error
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ type RuntimeBundleVerifier = (
|
||||
) => Promise<VerifiedRuntimeBundle>
|
||||
|
||||
export type RemoteRuntimeResourceLoaderOptions = {
|
||||
runtimeId?: 'opencode' | 'continue'
|
||||
verificationEnvironment?: 'production' | 'test'
|
||||
verifyRuntimeBundle?: RuntimeBundleVerifier
|
||||
}
|
||||
@@ -74,7 +75,7 @@ export async function loadVerifiedRemoteRuntimeResourceBundle(
|
||||
const metadata =
|
||||
await loadRemoteRuntimeVerificationMetadata(paths)
|
||||
const bundleDirectory = await resolveSingleDigestDirectory(
|
||||
getBundledRemoteRuntimeRoot(paths, architecture)
|
||||
getBundledRemoteRuntimeRoot(paths, architecture, options.runtimeId)
|
||||
)
|
||||
const verifier = options.verifyRuntimeBundle ?? verifyRuntimeBundle
|
||||
const verified = await verifier(bundleDirectory, {
|
||||
@@ -89,7 +90,8 @@ export async function loadVerifiedRemoteRuntimeResourceBundle(
|
||||
bundleDirectory,
|
||||
architecture,
|
||||
metadata.canonicalReleaseKeyRegistryBytes,
|
||||
metadata.canonicalRemoteRuntimeLockBytes
|
||||
metadata.canonicalRemoteRuntimeLockBytes,
|
||||
options.runtimeId ?? 'opencode'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -258,15 +260,16 @@ async function validateVerifiedBundle(
|
||||
bundleDirectory: string,
|
||||
architecture: AgentArchitecture,
|
||||
keyRegistryBytes: Buffer,
|
||||
runtimeLockBytes: Buffer
|
||||
runtimeLockBytes: Buffer,
|
||||
runtimeId: 'opencode' | 'continue'
|
||||
): Promise<VerifiedRemoteRuntimeResourceBundle> {
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(
|
||||
verified.manifest
|
||||
)
|
||||
if (
|
||||
verified.bundleDirectory !== bundleDirectory ||
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.provider !== 'opencode' ||
|
||||
manifest.runtimeId !== runtimeId ||
|
||||
manifest.provider !== runtimeId ||
|
||||
(manifest.platform !== 'linux' && !(manifest.platform === 'darwin' && manifest.architecture === 'arm64')) ||
|
||||
manifest.architecture !== architecture
|
||||
) {
|
||||
|
||||
@@ -32,7 +32,7 @@ const lifecycleActions = [
|
||||
'stop'
|
||||
] as const
|
||||
const runtimeArchitectures = ['x64', 'arm64'] as const
|
||||
const RUNTIME_ID = 'opencode' as const
|
||||
const runtimeIds = ['opencode', 'continue'] as const
|
||||
|
||||
declare const verifiedInstallationIdBrand: unique symbol
|
||||
|
||||
@@ -46,7 +46,7 @@ export type AgentRuntimeArchitecture =
|
||||
|
||||
export type AgentRuntimeActivationAction = {
|
||||
kind: 'runtime-activate'
|
||||
runtimeId: typeof RUNTIME_ID
|
||||
runtimeId: (typeof runtimeIds)[number]
|
||||
bundleDigest: string
|
||||
architecture: AgentRuntimeArchitecture
|
||||
forceVerification?: true
|
||||
@@ -216,7 +216,7 @@ export function buildFixedAgentCliArgv(
|
||||
}
|
||||
return [action.action, ...installationOption]
|
||||
case 'runtime-activate': {
|
||||
if (action.runtimeId !== RUNTIME_ID) {
|
||||
if (!runtimeIds.includes(action.runtimeId)) {
|
||||
throw new Error('GoodBuddy Agent Runtime ID 无效')
|
||||
}
|
||||
if (!sha256DigestSchema.safeParse(action.bundleDigest).success) {
|
||||
|
||||
@@ -47,8 +47,8 @@ type ExpectedCatalog = {
|
||||
version: string
|
||||
}
|
||||
runtimes: Array<{
|
||||
runtimeId: 'opencode'
|
||||
provider: 'opencode'
|
||||
runtimeId: 'opencode' | 'continue'
|
||||
provider: 'opencode' | 'continue'
|
||||
version: string
|
||||
}>
|
||||
}
|
||||
@@ -480,7 +480,7 @@ async function loadExpectedCatalog(
|
||||
},
|
||||
runtimes: Object.entries(runtimeLock.runtimes)
|
||||
.map(([runtimeId, runtime]) => ({
|
||||
runtimeId: runtimeId as 'opencode',
|
||||
runtimeId: runtimeId as 'opencode' | 'continue',
|
||||
provider: runtime.provider,
|
||||
version: runtime.version
|
||||
}))
|
||||
|
||||
@@ -134,7 +134,7 @@ export type SshRemotePackageAgentIdentity = {
|
||||
};
|
||||
|
||||
export type SshRemotePackageRuntimeIdentity = {
|
||||
runtimeId: "opencode";
|
||||
runtimeId: "opencode" | "continue";
|
||||
runtimeVersion: string;
|
||||
bundleDigest: string;
|
||||
manifestDigest: string;
|
||||
@@ -149,6 +149,7 @@ export type SshRemotePackageIdentity = {
|
||||
archiveSha256: string;
|
||||
agent: SshRemotePackageAgentIdentity;
|
||||
runtime: SshRemotePackageRuntimeIdentity;
|
||||
additionalRuntimes?: SshRemotePackageRuntimeIdentity[];
|
||||
};
|
||||
|
||||
export type SshRemotePackageBootstrapPrepareResult =
|
||||
@@ -275,6 +276,7 @@ type TerminalMessage =
|
||||
archiveSha256: string;
|
||||
agent: SshRemotePackageAgentIdentity;
|
||||
runtime: SshRemotePackageRuntimeIdentity;
|
||||
additionalRuntimes?: SshRemotePackageRuntimeIdentity[];
|
||||
}
|
||||
| {
|
||||
type: "result";
|
||||
@@ -935,6 +937,7 @@ function parsePackageIdentity(
|
||||
"archiveSha256",
|
||||
"agent",
|
||||
"runtime",
|
||||
...(message.additionalRuntimes === undefined ? [] : ["additionalRuntimes"]),
|
||||
]) ||
|
||||
message.type !== "result" ||
|
||||
message.command !== action ||
|
||||
@@ -985,7 +988,7 @@ function parsePackageIdentity(
|
||||
(architecture !== "x64" && architecture !== "arm64") ||
|
||||
!agentProtocol ||
|
||||
agent.supervisor !== "detached-on-demand" ||
|
||||
runtime.runtimeId !== "opencode" ||
|
||||
(runtime.runtimeId !== "opencode" && runtime.runtimeId !== "continue") ||
|
||||
!isVersion(runtime.runtimeVersion) ||
|
||||
typeof runtime.bundleDigest !== "string" ||
|
||||
!DIGEST_PATTERN.test(runtime.bundleDigest) ||
|
||||
@@ -1001,10 +1004,22 @@ function parsePackageIdentity(
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const additionalRuntimes: SshRemotePackageRuntimeIdentity[] = [];
|
||||
if (message.additionalRuntimes !== undefined) {
|
||||
if (!Array.isArray(message.additionalRuntimes) || message.additionalRuntimes.length > 1) return undefined;
|
||||
for (const extra of message.additionalRuntimes) {
|
||||
const { additionalRuntimes: _extras, ...base } = message;
|
||||
void _extras;
|
||||
const parsed = parsePackageIdentity({ ...base, runtime: extra }, action, candidate);
|
||||
if (!parsed || !("runtime" in parsed) || parsed.runtime.runtimeId === runtime.runtimeId) return undefined;
|
||||
additionalRuntimes.push(parsed.runtime);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "result",
|
||||
command: action,
|
||||
status: expectedStatus,
|
||||
...(additionalRuntimes.length ? { additionalRuntimes } : {}),
|
||||
archiveSha256: candidate.sha256,
|
||||
agent: {
|
||||
installationId: agent.installationId,
|
||||
@@ -1017,7 +1032,7 @@ function parsePackageIdentity(
|
||||
supervisor: "detached-on-demand",
|
||||
},
|
||||
runtime: {
|
||||
runtimeId: "opencode",
|
||||
runtimeId: runtime.runtimeId,
|
||||
runtimeVersion: runtime.runtimeVersion,
|
||||
bundleDigest: runtime.bundleDigest,
|
||||
manifestDigest: runtime.manifestDigest,
|
||||
@@ -1444,6 +1459,7 @@ export function createSshRemotePackageBootstrapExecutor(
|
||||
archiveSha256: result.archiveSha256,
|
||||
agent: result.agent,
|
||||
runtime: result.runtime,
|
||||
...(result.additionalRuntimes ? { additionalRuntimes: result.additionalRuntimes } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1487,6 +1503,7 @@ export function createSshRemotePackageBootstrapExecutor(
|
||||
archiveSha256: result.archiveSha256,
|
||||
agent: result.agent,
|
||||
runtime: result.runtime,
|
||||
...(result.additionalRuntimes ? { additionalRuntimes: result.additionalRuntimes } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1602,6 +1619,7 @@ export function createSshRemotePackageBootstrapExecutor(
|
||||
archiveSha256: result.archiveSha256,
|
||||
agent: result.agent,
|
||||
runtime: result.runtime,
|
||||
...(result.additionalRuntimes ? { additionalRuntimes: result.additionalRuntimes } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1245,7 +1245,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
expect(input).toHaveValue('/typed/path')
|
||||
})
|
||||
|
||||
it('saves Execute in one request without extra confirmation checklists', async () => {
|
||||
it.each(['opencode', 'continue'])('saves %s Execute in one request without extra confirmation checklists', async (provider) => {
|
||||
const api = installRemoteApi()
|
||||
const { onRemoteCommitted } = renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
@@ -1264,6 +1264,9 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
|
||||
target: { value: '/srv/project' }
|
||||
})
|
||||
fireEvent.change(within(dialog).getByRole('combobox', { name: '新对话默认 Runtime' }), {
|
||||
target: { value: provider }
|
||||
})
|
||||
const modeGroup = within(dialog)
|
||||
.getAllByRole('group', { name: '默认模式' })
|
||||
.find((candidate) =>
|
||||
@@ -1285,6 +1288,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
intent: 'create',
|
||||
draft: expect.objectContaining({
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: { provider },
|
||||
hostId,
|
||||
remoteRootPath: '/srv/project'
|
||||
})
|
||||
|
||||
@@ -927,7 +927,7 @@ export function ProjectSwitcher({
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
runtimeSelection:
|
||||
draft.runtimeSelection?.provider === 'opencode'
|
||||
draft.runtimeSelection?.provider === 'opencode' || draft.runtimeSelection?.provider === 'continue'
|
||||
? draft.runtimeSelection
|
||||
: ({ provider: 'opencode' } as const),
|
||||
hostId: remoteHostId,
|
||||
@@ -1478,8 +1478,7 @@ export function ProjectSwitcher({
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
runtimeSelection:
|
||||
current.runtimeSelection?.provider ===
|
||||
'opencode'
|
||||
current.runtimeSelection?.provider === 'opencode' || current.runtimeSelection?.provider === 'continue'
|
||||
? current.runtimeSelection
|
||||
: { provider: 'opencode' }
|
||||
}))
|
||||
@@ -1707,19 +1706,27 @@ export function ProjectSwitcher({
|
||||
{t('projectSwitcher.remote.rootHelp')}
|
||||
</small>
|
||||
</label>
|
||||
<div className="remote-project-runtime">
|
||||
<label className="remote-project-runtime">
|
||||
<span>
|
||||
{t(
|
||||
'projectSwitcher.dialog.fields.defaultRuntime'
|
||||
)}
|
||||
</span>
|
||||
<strong>OpenCode</strong>
|
||||
<select aria-label={t('projectSwitcher.dialog.fields.defaultRuntime')}
|
||||
disabled={remoteFieldsDisabled} value={draft.runtimeSelection?.provider ?? 'opencode'}
|
||||
onChange={event => {
|
||||
const provider = event.target.value === 'continue' ? 'continue' : 'opencode'
|
||||
setDraft(current => ({ ...current, runtimeSelection: { provider } }))
|
||||
}}>
|
||||
<option value="opencode">OpenCode</option>
|
||||
<option value="continue">Continue</option>
|
||||
</select>
|
||||
<small>
|
||||
{t(
|
||||
'projectSwitcher.remote.runtimeHelp'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
<ProjectWorkModeFields
|
||||
ariaLabel={t(
|
||||
'projectSwitcher.dialog.fields.defaultMode'
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Message } from './ChatTimeline'
|
||||
import { RuntimeChecklistStrip } from './RuntimeChecklistStrip'
|
||||
import { ConversationTaskStrip } from './ConversationTaskStrip'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const message: Message = {
|
||||
id: 'request-message', role: 'assistant', content: '', createdAt: 1, state: 'streaming',
|
||||
runtimeChecklist: { source: 'opencode', items: [
|
||||
{ content: 'Inspect', status: 'completed' },
|
||||
{ content: 'Implement', status: 'in_progress', priority: 'high' },
|
||||
{ content: 'Verify', status: 'pending' },
|
||||
{ content: 'Omitted', status: 'cancelled' }
|
||||
] }
|
||||
}
|
||||
|
||||
describe('RuntimeChecklistStrip', () => {
|
||||
it('is read-only, preserves collapse and focus during full replacements, and retains terminal item states', () => {
|
||||
const { rerender } = render(<RuntimeChecklistStrip messages={[message]} />)
|
||||
const toggle = screen.getByRole('button', { name: /执行清单/ })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
toggle.focus()
|
||||
fireEvent.click(toggle)
|
||||
expect(document.getElementById(toggle.getAttribute('aria-controls')!)).toBeVisible()
|
||||
expect(screen.getByRole('group', { name: '执行清单' })).toHaveAttribute('tabindex', '0')
|
||||
expect(screen.getByText('高优先级')).toBeVisible()
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
const updated: Message = { ...message, state: 'error', status: '已取消', runtimeChecklist: {
|
||||
source: 'continue', items: [
|
||||
{ content: 'Duplicate', status: 'pending' }, { content: 'Duplicate', status: 'completed' }
|
||||
]
|
||||
} }
|
||||
rerender(<RuntimeChecklistStrip messages={[updated]} />)
|
||||
expect(toggle).toHaveFocus()
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(toggle).toHaveTextContent('已完成 1/2')
|
||||
expect(toggle).toHaveTextContent('已取消')
|
||||
expect(screen.getAllByText('Duplicate')).toHaveLength(2)
|
||||
expect(screen.getByText('待处理')).toBeVisible()
|
||||
fireEvent.click(toggle)
|
||||
rerender(<RuntimeChecklistStrip messages={[{ ...updated, status: '失败' }]} />)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(toggle).toHaveFocus()
|
||||
})
|
||||
|
||||
it('selects the current request before looking at data and hides explicit empty snapshots', () => {
|
||||
const next: Message = { id: 'next', role: 'assistant', content: '', createdAt: 2, state: 'streaming' }
|
||||
const { rerender } = render(<RuntimeChecklistStrip messages={[message, next]} />)
|
||||
expect(screen.queryByRole('region')).not.toBeInTheDocument()
|
||||
rerender(<RuntimeChecklistStrip messages={[message, next]} activeMessageId="missing" />)
|
||||
expect(screen.queryByRole('region')).not.toBeInTheDocument()
|
||||
rerender(<RuntimeChecklistStrip messages={[message, next]} activeMessageId={message.id} />)
|
||||
expect(screen.getByRole('region', { name: '执行清单' })).toBeVisible()
|
||||
rerender(<RuntimeChecklistStrip messages={[message, { ...next, runtimeChecklist: { source: 'opencode', items: [] } }]} />)
|
||||
expect(screen.queryByRole('region')).not.toBeInTheDocument()
|
||||
rerender(<RuntimeChecklistStrip messages={[{ ...message, state: 'complete' }, next]} activeMessageId={message.id} />)
|
||||
expect(screen.queryByRole('region')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('coexists with independent Task expansion and keeps the existing Task action', () => {
|
||||
const select = vi.fn()
|
||||
render(<div className="conversation-context-strips">
|
||||
<ConversationTaskStrip locale="zh-CN" schedules={[]} tasks={[{
|
||||
id: 'task', title: 'Existing Task', status: 'running', createdAt: new Date().toISOString(),
|
||||
instructions: 'Work', origin: 'schedule'
|
||||
}]} onSelectTask={select} onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()} onSetScheduleEnabled={vi.fn()} />
|
||||
<RuntimeChecklistStrip messages={[message]} />
|
||||
</div>)
|
||||
const checklistToggle = screen.getByRole('button', { name: /执行清单/ })
|
||||
const taskToggle = document.querySelector<HTMLButtonElement>('.conversation-task-strip__toggle')!
|
||||
fireEvent.click(taskToggle)
|
||||
expect(checklistToggle).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(checklistToggle)
|
||||
expect(taskToggle).toHaveAttribute('aria-expanded', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Existing Task' }))
|
||||
expect(select).toHaveBeenCalledWith('task')
|
||||
fireEvent.click(taskToggle)
|
||||
expect(within(screen.getByRole('region', { name: '执行清单' })).getByRole('list')).toBeVisible()
|
||||
})
|
||||
|
||||
it('bounds combined height and long content while retaining wrapping and keyboard focus', () => {
|
||||
const css = readFileSync('src/renderer/src/runtime-checklist.css', 'utf8')
|
||||
expect(css).toMatch(/\.conversation-context-strips\s*\{[^}]*max-height: 45vh;[^}]*overflow: auto;/s)
|
||||
expect(css).toMatch(/\.runtime-checklist__toggle\s*\{\s*flex: 0 0 auto;/s)
|
||||
expect(css).toMatch(/\.runtime-checklist__content\s*\{[^}]*min-height: 0;/s)
|
||||
expect(css).toMatch(/\.runtime-checklist__content\s*\{[^}]*max-height: 20vh;[^}]*overflow: auto;/s)
|
||||
expect(css).toContain('overflow-wrap: anywhere')
|
||||
expect(css).toContain('flex-wrap: wrap')
|
||||
expect(css).toContain(':focus-visible')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Message } from './ChatTimeline'
|
||||
import './runtime-checklist.css'
|
||||
|
||||
const statusIcons = {
|
||||
pending: Circle,
|
||||
in_progress: CircleDashed,
|
||||
completed: CheckCircle2,
|
||||
cancelled: XCircle
|
||||
}
|
||||
|
||||
export function RuntimeChecklistStrip({ messages, activeMessageId }: {
|
||||
messages: readonly Message[]
|
||||
activeMessageId?: string
|
||||
}): React.JSX.Element | null {
|
||||
// Select the request first, never search backwards for a populated checklist.
|
||||
const activeMessage = activeMessageId
|
||||
? messages.find(candidate => candidate.id === activeMessageId)
|
||||
: undefined
|
||||
const message = activeMessageId && (!activeMessage || activeMessage.state === 'streaming')
|
||||
? activeMessage
|
||||
: messages.at(-1)
|
||||
if (message?.role !== 'assistant' || !message.runtimeChecklist?.items.length) return null
|
||||
return <Checklist key={message.id} message={message} />
|
||||
}
|
||||
|
||||
function Checklist({ message }: { message: Message }): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const contentId = useId()
|
||||
const items = message.runtimeChecklist!.items
|
||||
const completed = items.reduce((count, item) => count + Number(item.status === 'completed'), 0)
|
||||
const current = items.find(item => item.status === 'in_progress')
|
||||
const result = message.state === 'streaming' ? undefined : message.status || t(
|
||||
message.state === 'error' ? 'runtimeChecklist.failed' : 'runtimeChecklist.finished'
|
||||
)
|
||||
const summary = t('runtimeChecklist.count', { completed, total: items.length })
|
||||
return (
|
||||
<section className="runtime-checklist" aria-label={t('runtimeChecklist.title')}>
|
||||
<button className="runtime-checklist__toggle" type="button"
|
||||
aria-expanded={expanded} aria-controls={contentId}
|
||||
onClick={() => setExpanded(value => !value)}>
|
||||
<strong>{t('runtimeChecklist.title')}</strong>
|
||||
<span>{summary}</span>
|
||||
{result && <span className="runtime-checklist__result">{result}</span>}
|
||||
{current && <span className="runtime-checklist__current" title={current.content}>{current.content}</span>}
|
||||
{expanded ? <ChevronUp aria-hidden="true" size={14} /> : <ChevronDown aria-hidden="true" size={14} />}
|
||||
</button>
|
||||
<span className="sr-only" role="status" aria-live="polite">{summary}{result ? ` · ${result}` : ''}</span>
|
||||
<div id={contentId} hidden={!expanded} className="runtime-checklist__content"
|
||||
tabIndex={0} role="group" aria-label={t('runtimeChecklist.title')}>
|
||||
<ol>
|
||||
{items.map((item, index) => {
|
||||
const Icon = statusIcons[item.status]
|
||||
return <li key={index}>
|
||||
<span className={`runtime-checklist__status runtime-checklist__status--${item.status}`}>
|
||||
<Icon aria-hidden="true" size={14} /><span>{t(`runtimeChecklist.status.${item.status}`)}</span>
|
||||
</span>
|
||||
<span className="runtime-checklist__text">{item.content}</span>
|
||||
{item.priority && <small>{t(`runtimeChecklist.priority.${item.priority}`)}</small>}
|
||||
</li>
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,12 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { app as chineseApp } from '../zh-CN/app'
|
||||
|
||||
export const app = {
|
||||
runtimeChecklist: {
|
||||
title: 'Execution checklist', count: '{{completed}}/{{total}} completed',
|
||||
finished: 'Request completed', failed: 'Request failed',
|
||||
status: { pending: 'Pending', in_progress: 'In progress', completed: 'Completed', cancelled: 'Cancelled' },
|
||||
priority: { low: 'Low priority', medium: 'Medium priority', high: 'High priority' }
|
||||
},
|
||||
applications: {
|
||||
alwaysShown: 'Always shown', optional: 'Optional',
|
||||
manage: 'Manage Apps',
|
||||
|
||||
@@ -573,7 +573,7 @@ export const settingsSections = {
|
||||
agentInventory: {
|
||||
title: 'GoodBuddy Agent packages',
|
||||
description:
|
||||
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode Runtime adapted by GoodBuddy. Opening this page reads only the small signed catalog to show available updates and never downloads an Agent package automatically. Online actions use the source selected under About & Updates.',
|
||||
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode and Continue Runtimes adapted by GoodBuddy. Opening this page reads only the small signed catalog to show available updates and never downloads an Agent package automatically. Online actions use the source selected under About & Updates.',
|
||||
loading: 'Verifying local Agent packages and checking online versions…',
|
||||
refresh: 'Refresh package inventory',
|
||||
import: 'Import offline package',
|
||||
|
||||
@@ -68,7 +68,7 @@ export const workspace = {
|
||||
stages: {
|
||||
network: 'Restoring network connection…',
|
||||
agent: 'Restoring remote Agent…',
|
||||
runtime: 'Restoring OpenCode Runtime…',
|
||||
runtime: 'Restoring remote Runtime…',
|
||||
cursor: 'Restoring conversation at event {{current}}',
|
||||
completed: 'Recovery completed'
|
||||
},
|
||||
@@ -172,7 +172,7 @@ export const workspace = {
|
||||
select: 'Select this directory'
|
||||
},
|
||||
runtimeHelp:
|
||||
'Managed SSH projects use OpenCode Runtime only. Ask is read-only; Execute can use every permission available to the selected SSH account. Saving checks the Host, Agent, workspace, and Runtime.',
|
||||
'Managed SSH projects can use an installed OpenCode or Continue Runtime. Ask is read-only; Execute can use every permission available to the selected SSH account. Saving checks the Host, Agent, workspace, and selected Runtime.',
|
||||
actions: {
|
||||
save: 'Save remote project',
|
||||
saving: 'Saving remote project…'
|
||||
@@ -186,7 +186,7 @@ export const workspace = {
|
||||
host: 'SSH host',
|
||||
agent: 'Remote Agent',
|
||||
workspace: 'Remote workspace',
|
||||
runtime: 'OpenCode Runtime',
|
||||
runtime: 'Remote Runtime',
|
||||
saving: 'Project settings'
|
||||
},
|
||||
validation: {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export const app = {
|
||||
runtimeChecklist: {
|
||||
title: '执行清单', count: '已完成 {{completed}}/{{total}}',
|
||||
finished: '请求已完成', failed: '请求失败',
|
||||
status: { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' },
|
||||
priority: { low: '低优先级', medium: '中优先级', high: '高优先级' }
|
||||
},
|
||||
applications: {
|
||||
alwaysShown: '始终显示', optional: '可选',
|
||||
manage: '管理应用',
|
||||
|
||||
@@ -543,7 +543,7 @@ export const settingsSections = {
|
||||
agentInventory: {
|
||||
title: 'GoodBuddy Agent 包',
|
||||
description:
|
||||
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode Runtime。打开此页只读取小型签名目录并显示可用更新,不会自动下载 Agent 包;在线操作使用“关于与更新”中选择的更新源。',
|
||||
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode、Continue Runtime。打开此页只读取小型签名目录并显示可用更新,不会自动下载 Agent 包;在线操作使用“关于与更新”中选择的更新源。',
|
||||
loading: '正在校验本地 Agent 包并检查在线版本…',
|
||||
refresh: '刷新包清单',
|
||||
import: '导入离线包',
|
||||
|
||||
@@ -65,7 +65,7 @@ export const workspace = {
|
||||
stages: {
|
||||
network: '正在恢复网络连接…',
|
||||
agent: '正在恢复远端 Agent…',
|
||||
runtime: '正在恢复 OpenCode Runtime…',
|
||||
runtime: '正在恢复远端 Runtime…',
|
||||
cursor: '正在恢复对话,已同步到事件 {{current}}',
|
||||
completed: '恢复完成'
|
||||
},
|
||||
@@ -167,7 +167,7 @@ export const workspace = {
|
||||
select: '选择此目录'
|
||||
},
|
||||
runtimeHelp:
|
||||
'托管 SSH 项目仅使用 OpenCode Runtime。Ask 为只读;Execute 可使用所选 SSH 账户拥有的全部权限。保存时会检查主机、远端 Agent、工作区和 Runtime。',
|
||||
'托管 SSH 项目可使用已安装的 OpenCode 或 Continue Runtime。Ask 为只读;Execute 可使用所选 SSH 账户拥有的全部权限。保存时会检查主机、远端 Agent、工作区和所选 Runtime。',
|
||||
actions: {
|
||||
save: '保存远程项目',
|
||||
saving: '正在保存远程项目…'
|
||||
@@ -181,7 +181,7 @@ export const workspace = {
|
||||
host: 'SSH 主机',
|
||||
agent: '远端 Agent',
|
||||
workspace: '远端工作区',
|
||||
runtime: 'OpenCode Runtime',
|
||||
runtime: '远端 Runtime',
|
||||
saving: '项目设置'
|
||||
},
|
||||
validation: {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
.conversation-context-strips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-height: 45vh;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.conversation-context-strips > .conversation-task-strip,
|
||||
.conversation-context-strips > .runtime-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 1 auto;
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.conversation-context-strips .conversation-task-strip__header,
|
||||
.runtime-checklist__toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.conversation-context-strips .conversation-task-strip__content {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 20vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.runtime-checklist {
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
padding: 0 max(var(--page-gutter), calc((100% - var(--content-reading)) / 2));
|
||||
}
|
||||
|
||||
.runtime-checklist__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-checklist__toggle:hover { background: var(--surface-subtle); }
|
||||
.runtime-checklist__toggle:focus-visible,
|
||||
.runtime-checklist__content:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.runtime-checklist__toggle strong { color: var(--text-primary); }
|
||||
.runtime-checklist__toggle svg { flex: 0 0 auto; }
|
||||
.runtime-checklist__current {
|
||||
flex: 1 1 6em;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.runtime-checklist__result { overflow-wrap: anywhere; }
|
||||
.runtime-checklist__content { flex: 0 1 auto; min-height: 0; max-height: 20vh; overflow: auto; }
|
||||
.runtime-checklist__content ol { margin: 0; padding: var(--space-2); list-style: none; }
|
||||
.runtime-checklist__content li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
.runtime-checklist__text { flex: 1 1 12em; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.runtime-checklist__status { display: inline-flex; align-items: baseline; gap: var(--space-1); }
|
||||
.runtime-checklist__status svg { align-self: center; flex: 0 0 auto; }
|
||||
.runtime-checklist__status--completed { color: var(--success); }
|
||||
.runtime-checklist__status--in_progress { color: var(--accent); }
|
||||
.runtime-checklist__content small { font-size: inherit; }
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"runtime-checklist-visual-validation","version":"1.0.0","main":"runtime-checklist-electron.mjs","private":true}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { setTimeout as wait } from 'node:timers/promises'
|
||||
const evidence = process.env.CHECKLIST_VISUAL_EVIDENCE
|
||||
const results = []
|
||||
let window
|
||||
const evaluate = script => window.webContents.executeJavaScript(script, true)
|
||||
const click = selector => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`)
|
||||
const check = async (name, script) => {
|
||||
const passed = await evaluate(script)
|
||||
results.push({ name, passed })
|
||||
console.log(name, passed ? 'PASS' : 'FAIL')
|
||||
}
|
||||
async function key(keyCode, modifiers = []) {
|
||||
if (keyCode === 'Enter') keyCode = 'Return'
|
||||
window.focus()
|
||||
window.webContents.focus()
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
|
||||
if (keyCode === 'Return') window.webContents.sendInputEvent({ type: 'char', keyCode: '\r', modifiers })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
|
||||
await wait(100)
|
||||
}
|
||||
async function capture(name) {
|
||||
await wait(150)
|
||||
const measurements = await evaluate(`(() => {
|
||||
const rect = selector => { const e = document.querySelector(selector); if (!e) return null; const r = e.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,clientWidth:e.clientWidth,scrollWidth:e.scrollWidth } };
|
||||
return { viewport: {width:innerWidth,height:innerHeight,dpr:devicePixelRatio}, body:rect('body'), top:rect('.conversation-context-strips'), task:rect('.conversation-task-strip__toggle'), checklist:rect('.runtime-checklist__toggle'), list:rect('.runtime-checklist__content'), chat:rect('.chat'), input:rect('input'), focus:document.activeElement?.className };
|
||||
})()`)
|
||||
const image = await window.webContents.capturePage()
|
||||
writeFileSync(join(evidence, `${name}.png`), image.toPNG())
|
||||
results.push({ name, ...measurements })
|
||||
results.push({ name: `${name}-layout`, passed: measurements.body.scrollWidth <= measurements.body.clientWidth + 1 && measurements.input.bottom <= measurements.viewport.height + 1 && measurements.chat.height > 40 && (!measurements.checklist || measurements.checklist.bottom <= measurements.top.bottom + 1) && (!measurements.task || measurements.task.y >= measurements.top.y - 1) })
|
||||
console.log(name, JSON.stringify(measurements))
|
||||
}
|
||||
app.whenReady().then(async () => {
|
||||
mkdirSync(evidence, { recursive: true })
|
||||
window = new BrowserWindow({ width: 1440, height: 1000, useContentSize: true, show: true, autoHideMenuBar: true, webPreferences: { contextIsolation: true, sandbox: true } })
|
||||
window.webContents.on('console-message', event => console.log('renderer:', event.message))
|
||||
await window.loadURL(process.env.CHECKLIST_VISUAL_URL)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (await evaluate('Boolean(window.checklistHarness)')) break
|
||||
await wait(100)
|
||||
}
|
||||
if (process.env.CHECKLIST_VISUAL_BASELINE === '1') {
|
||||
await evaluate('document.fonts.ready.then(() => true)')
|
||||
await click('.runtime-checklist__toggle')
|
||||
for (const [name, width, height, zoom] of [
|
||||
['wide-100', 1440, 1000, 1], ['narrow-100', 760, 800, 1],
|
||||
['wide-200', 1440, 1000, 2], ['narrow-200', 760, 800, 2]
|
||||
]) {
|
||||
window.setContentSize(width, height)
|
||||
window.focus()
|
||||
await wait(300)
|
||||
window.webContents.setZoomFactor(zoom)
|
||||
await window.webContents.capturePage()
|
||||
let viewportReady = false
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
await wait(100)
|
||||
viewportReady = await evaluate(`Math.abs(innerWidth - ${width / zoom}) < 3 && Math.abs(innerHeight - ${height / zoom}) < 3`)
|
||||
if (viewportReady) break
|
||||
}
|
||||
if (!viewportReady) throw new Error(`Viewport did not settle for ${name}: ${JSON.stringify(await evaluate('({width:innerWidth,height:innerHeight,dpr:devicePixelRatio})'))}; native=${window.getContentSize()}; zoom=${window.webContents.getZoomFactor()}`)
|
||||
for (const index of [0, 1, 3, 4]) {
|
||||
await evaluate(`document.querySelectorAll('.runtime-checklist__content li')[${index}].scrollIntoView({block:'start'})`)
|
||||
await wait(100)
|
||||
const measured = await evaluate(`(() => {
|
||||
const item = document.querySelectorAll('.runtime-checklist__content li')[${index}];
|
||||
const label = item.querySelector('.runtime-checklist__status > span');
|
||||
const text = item.querySelector('.runtime-checklist__text');
|
||||
const range = document.createRange();
|
||||
const rect = r => ({top:r.top,bottom:r.bottom,height:r.height,left:r.left,right:r.right});
|
||||
range.selectNodeContents(label);
|
||||
const labelText = rect(range.getClientRects()[0]);
|
||||
range.selectNodeContents(text);
|
||||
const lines = Array.from(range.getClientRects()).filter(r => r.width > 0);
|
||||
const firstLine = rect(lines[0]);
|
||||
const labelSpan = rect(label.getBoundingClientRect());
|
||||
return {status:label.textContent, content:text.textContent, viewport:{width:innerWidth,height:innerHeight,dpr:devicePixelRatio},
|
||||
lineCount:new Set(lines.map(r => r.top)).size, labelSpan, labelText, firstLine,
|
||||
spanTopDelta:labelSpan.top-firstLine.top,spanBottomDelta:labelSpan.bottom-firstLine.bottom,
|
||||
textTopDelta:labelText.top-firstLine.top,textBottomDelta:labelText.bottom-firstLine.bottom};
|
||||
})()`)
|
||||
const passed = [measured.textTopDelta, measured.textBottomDelta, measured.spanTopDelta, measured.spanBottomDelta]
|
||||
.every(delta => Math.abs(delta) < 0.1)
|
||||
results.push({ name, index, zoom, passed, ...measured })
|
||||
console.log('baseline', JSON.stringify({ name, index, passed, ...measured, content: undefined }))
|
||||
if (index === 0 || index === 3) {
|
||||
writeFileSync(join(evidence, `${name}-item-${index + 1}.png`), (await window.webContents.capturePage()).toPNG())
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(join(evidence, 'baseline-results.json'), JSON.stringify({ environment: {
|
||||
platform:process.platform,electron:process.versions.electron,chrome:process.versions.chrome,
|
||||
path:'production renderer components in Electron harness; not full desktop IPC',units:'CSS pixels'
|
||||
}, results }, null, 2))
|
||||
app.exit(results.some(result => !result.passed) ? 1 : 0)
|
||||
return
|
||||
}
|
||||
await capture('01-wide-collapsed')
|
||||
await click('.conversation-task-strip__toggle')
|
||||
await click('.runtime-checklist__toggle')
|
||||
await capture('02-wide-both-expanded')
|
||||
await evaluate("document.querySelector('.runtime-checklist__toggle').focus()")
|
||||
await key('Space')
|
||||
await check('space-collapses-only-checklist', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'false' && document.querySelector('.conversation-task-strip__toggle').getAttribute('aria-expanded') === 'true'")
|
||||
await key('Enter')
|
||||
await check('enter-expands-checklist-keeps-focus', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'true' && document.activeElement.matches('.runtime-checklist__toggle')")
|
||||
await evaluate("document.querySelector('input').focus(); window.checklistHarness.update()")
|
||||
results.push({ name: 'update-keeps-input-focus', passed: await evaluate("document.activeElement === document.querySelector('input')") })
|
||||
await evaluate("document.querySelector('.runtime-checklist__content').scrollTop = 100000")
|
||||
await capture('03-long-list-end')
|
||||
window.webContents.setZoomFactor(2)
|
||||
await capture('03b-wide-200-percent')
|
||||
window.webContents.setZoomFactor(1)
|
||||
window.setContentSize(760, 800)
|
||||
await capture('04-narrow-both-expanded')
|
||||
window.webContents.setZoomFactor(2)
|
||||
await capture('05-narrow-200-percent')
|
||||
await evaluate("document.querySelector('.runtime-checklist__toggle').focus()")
|
||||
await key('Tab')
|
||||
await check('tab-reaches-checklist-scroll-region', "document.activeElement.matches('.runtime-checklist__content')")
|
||||
await key('End', ['control'])
|
||||
await wait(300)
|
||||
await check('keyboard-reaches-last-item-at-200-percent', "(() => {const list=document.querySelector('.runtime-checklist__content'); const last=list.querySelector('li:last-child').getBoundingClientRect(); const r=list.getBoundingClientRect(); return document.activeElement === list && last.bottom <= r.bottom + 1 && last.bottom > r.top;})()")
|
||||
await evaluate("document.querySelector('.conversation-task-details__actions button').focus()")
|
||||
await key('Enter')
|
||||
await check('task-run-action-at-200-percent', 'window.checklistHarness.runs === 1')
|
||||
await capture('05b-narrow-keyboard-task-action')
|
||||
await evaluate("window.checklistHarness.theme('dark')")
|
||||
await check('dark-theme-applied-to-root', "document.documentElement.dataset.theme === 'dark' && getComputedStyle(document.documentElement).getPropertyValue('--surface-raised').trim() !== '#ffffff'")
|
||||
await capture('06-dark-narrow-200-percent')
|
||||
window.webContents.setZoomFactor(1)
|
||||
window.setContentSize(1440, 1000)
|
||||
await evaluate("window.checklistHarness.scenario('next')")
|
||||
results.push({ name: 'new-request-hides-old-checklist', passed: await evaluate("!document.querySelector('.runtime-checklist')") })
|
||||
await capture('07-new-request')
|
||||
await evaluate("window.checklistHarness.scenario('stale')")
|
||||
await check('stale-terminal-request-does-not-revive-old-checklist', "!document.querySelector('.runtime-checklist')")
|
||||
await evaluate("window.checklistHarness.scenario('cleared')")
|
||||
results.push({ name: 'empty-hides-old-checklist', passed: await evaluate("!document.querySelector('.runtime-checklist')") })
|
||||
await evaluate("window.checklistHarness.scenario('history')")
|
||||
await click('.runtime-checklist__toggle')
|
||||
await capture('08-cancelled-history')
|
||||
await evaluate("window.checklistHarness.scenario('remote')")
|
||||
await capture('09-remote-without-task')
|
||||
writeFileSync(join(evidence, 'results.json'), JSON.stringify({ environment: { platform:process.platform, electron:process.versions.electron, chrome:process.versions.chrome, node:process.versions.node, path:'component harness, not full desktop IPC path', sandbox:true, contextIsolation:true, gpu:'default', checklistItems:80 }, results }, null, 2))
|
||||
app.exit(results.some(result => result.passed === false) ? 1 : 0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createServer } from 'vite'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const server = await createServer({ configFile: false, root: process.cwd(), server: { host: '127.0.0.1', port: 0 }, esbuild: { jsx: 'automatic' } })
|
||||
await server.listen()
|
||||
const port = server.httpServer.address().port
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
env.CHECKLIST_VISUAL_URL = `http://127.0.0.1:${port}/src/renderer/validation/runtime-checklist.html`
|
||||
env.CHECKLIST_VISUAL_EVIDENCE = resolve(process.argv[2])
|
||||
env.CHECKLIST_VISUAL_BASELINE = process.argv.includes('--baseline') ? '1' : ''
|
||||
const child = spawn(require('electron'), [resolve('src/renderer/validation'), `--user-data-dir=${resolve(process.argv[2], 'profile')}`], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
child.stdout.pipe(process.stdout)
|
||||
child.stderr.pipe(process.stderr)
|
||||
child.on('exit', async code => { console.log('Electron exit:', code); await server.close(); process.exit(code ?? 1) })
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><title>Runtime checklist visual validation</title></head>
|
||||
<body><div id="root"></div><script type="module" src="./runtime-checklist.tsx"></script></body></html>
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { flushSync } from 'react-dom'
|
||||
import '../src/i18n'
|
||||
import '../src/styles.css'
|
||||
import { RuntimeChecklistStrip } from '../src/RuntimeChecklistStrip'
|
||||
import { ConversationTaskStrip } from '../src/ConversationTaskStrip'
|
||||
import type { Message } from '../src/ChatTimeline'
|
||||
|
||||
const original: Message = {
|
||||
id: 'original', role: 'assistant', content: '', createdAt: 1, state: 'streaming',
|
||||
runtimeChecklist: { source: 'opencode', items: Array.from({ length: 80 }, (_, index) => ({
|
||||
content: `${index + 1}. ${index === 1 ? '正在核对当前项目中的修改与既有任务,同时保留用户未提交的工作' : index === 79 ? 'LAST ITEM' : '检查生产组件布局、当前请求归属及历史状态'} ${index === 3 ? 'long_unbroken_path_'.repeat(35) : ''}`,
|
||||
status: index === 0 ? 'completed' : index === 1 ? 'in_progress' : index === 4 ? 'cancelled' : 'pending',
|
||||
priority: index % 3 === 0 ? 'high' : index % 3 === 1 ? 'medium' : 'low'
|
||||
})) }
|
||||
}
|
||||
const next: Message = { id: 'next', role: 'assistant', content: '', createdAt: 2, state: 'streaming' }
|
||||
const task = {
|
||||
id: 'task', title: '核对项目与构建验证', instructions: '保留用户修改', origin: 'schedule' as const,
|
||||
status: 'idle' as const, createdAt: '2026-09-18T00:00:00Z'
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const [scenario, setScenario] = useState('live')
|
||||
const [runs, setRuns] = useState(0)
|
||||
const [update, setUpdate] = useState(0)
|
||||
const [theme, setTheme] = useState('light')
|
||||
const messages = scenario === 'stale' ? [{ ...original, state: 'complete' as const }, next]
|
||||
: scenario === 'next' ? [original, next]
|
||||
: scenario === 'cleared' ? [original, { ...next, runtimeChecklist: { source: 'opencode' as const, items: [] } }]
|
||||
: scenario === 'history' || scenario === 'remote' ? [{ ...original, state: 'error' as const, status: '已取消' }]
|
||||
: [{ ...original, runtimeChecklist: { ...original.runtimeChecklist!, items: original.runtimeChecklist!.items.map((item, index) => index === 2 && update ? { ...item, content: `更新 ${update}`, status: 'completed' as const } : item) } }]
|
||||
Object.assign(window, { checklistHarness: {
|
||||
scenario: (value: string) => flushSync(() => setScenario(value)),
|
||||
update: () => flushSync(() => setUpdate(value => value + 1)),
|
||||
theme: (value: string) => {
|
||||
document.documentElement.dataset.theme = value
|
||||
document.documentElement.style.colorScheme = value
|
||||
flushSync(() => setTheme(value))
|
||||
},
|
||||
runs
|
||||
} })
|
||||
return <div data-theme={theme} style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: 'var(--surface-raised)', color: 'var(--text-primary)' }}>
|
||||
<header style={{ flex: '0 0 auto', padding: '8px 16px', background: 'var(--surface-subtle)' }}>Renderer component harness · {scenario} · {theme}</header>
|
||||
<main style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="chat-history-pane" style={{ flex: '1 1 auto', height: 'auto' }}>
|
||||
<div className="conversation-context-strips">
|
||||
{scenario !== 'remote' && <ConversationTaskStrip locale="zh-CN" tasks={[task]} schedules={[{
|
||||
id: 'schedule', taskId: 'task', title: task.title, instructions: task.instructions, enabled: true,
|
||||
recurrence: 'daily', nextRunAt: '2026-09-19T08:00:00Z', createdAt: task.createdAt, updatedAt: task.createdAt
|
||||
}]} onSelectTask={() => undefined} onRunSchedule={async () => { setRuns(value => value + 1) }} onRemoveSchedule={async () => undefined} onSetScheduleEnabled={async () => undefined} />}
|
||||
<RuntimeChecklistStrip messages={messages} activeMessageId={scenario === 'next' ? 'next' : scenario === 'stale' ? 'original' : undefined} />
|
||||
</div>
|
||||
<section className="chat"><div style={{ padding: '16px' }}>对话正文仍应可见。此页仅复用 renderer 组件,未连接 Main、IPC 或模型。</div></section>
|
||||
</div>
|
||||
<footer style={{ flex: '0 0 auto', padding: '16px', borderTop: '1px solid var(--border-default)' }}>
|
||||
<label>消息输入 <input aria-label="消息输入" placeholder="输入区保持可见" style={{ width: '100%', minWidth: 0 }} /></label>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<Harness />)
|
||||
@@ -80,6 +80,16 @@ export const agentPackageFileSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
const packagedRuntimeSchema = z.object({
|
||||
runtimeId: z.enum(['opencode', 'continue']),
|
||||
provider: z.enum(['opencode', 'continue']),
|
||||
version: semanticVersionSchema,
|
||||
bundleDigest: sha256DigestSchema,
|
||||
protocol: agentProtocolVersionSchema
|
||||
}).strict().refine(value => value.runtimeId === value.provider, {
|
||||
message: 'Runtime provider must match its identity'
|
||||
})
|
||||
|
||||
const agentPackageDescriptorObjectSchema = z
|
||||
.object({
|
||||
format: z.literal(AGENT_PACKAGE_FORMAT),
|
||||
@@ -92,15 +102,8 @@ const agentPackageDescriptorObjectSchema = z
|
||||
architecture: agentArchitectureSchema,
|
||||
signingKeyId: signingKeyIdSchema,
|
||||
agentProtocol: agentProtocolVersionSchema,
|
||||
remoteRuntime: z
|
||||
.object({
|
||||
runtimeId: z.literal('opencode'),
|
||||
provider: z.literal('opencode'),
|
||||
version: semanticVersionSchema,
|
||||
bundleDigest: sha256DigestSchema,
|
||||
protocol: agentProtocolVersionSchema
|
||||
})
|
||||
.strict(),
|
||||
remoteRuntime: packagedRuntimeSchema,
|
||||
additionalRuntimes: z.array(packagedRuntimeSchema).max(1).optional(),
|
||||
contentDigest: sha256DigestSchema,
|
||||
files: z
|
||||
.array(agentPackageFileSchema)
|
||||
@@ -112,6 +115,9 @@ const agentPackageDescriptorObjectSchema = z
|
||||
export const agentPackageDescriptorSchema =
|
||||
agentPackageDescriptorObjectSchema
|
||||
.superRefine((descriptor, context) => {
|
||||
if (descriptor.additionalRuntimes?.some(runtime => runtime.runtimeId === descriptor.remoteRuntime.runtimeId)) {
|
||||
context.addIssue({ code: 'custom', path: ['additionalRuntimes'], message: 'Duplicate packaged Runtime' })
|
||||
}
|
||||
const paths = descriptor.files.map((file) => file.path)
|
||||
if (new Set(paths).size !== paths.length) {
|
||||
context.addIssue({
|
||||
@@ -152,6 +158,9 @@ export const agentPackageCatalogEntrySchema =
|
||||
})
|
||||
.strict()
|
||||
.superRefine((entry, context) => {
|
||||
if (entry.additionalRuntimes?.some(runtime => runtime.runtimeId === entry.remoteRuntime.runtimeId)) {
|
||||
context.addIssue({ code: 'custom', path: ['additionalRuntimes'], message: 'Duplicate packaged Runtime' })
|
||||
}
|
||||
if (
|
||||
entry.archive !==
|
||||
agentPackageArchiveName(entry.version, entry.architecture, entry.platform)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { runtimeChecklistSchema } from './runtime-checklist'
|
||||
import { externalKnowledgeLocatorSchema } from './external-knowledge-contracts'
|
||||
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
|
||||
import { sshHostIdSchema } from './ssh-host-contracts'
|
||||
@@ -367,6 +368,7 @@ export const conversationMessageSchema = z
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string(),
|
||||
reasoning: z.string().optional(),
|
||||
runtimeChecklist: runtimeChecklistSchema.optional(),
|
||||
blocks: conversationMessageBlocksSchema.optional(),
|
||||
displayCaptureTruncated: z.boolean().optional(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
|
||||
@@ -1251,6 +1251,7 @@ export const subagentEventSchema = z.union([
|
||||
export type SubagentEvent = z.infer<typeof subagentEventSchema>
|
||||
|
||||
export type AgentEvent =
|
||||
| { type: 'checklist'; requestId: string; checklist: import('./runtime-checklist').RuntimeChecklist }
|
||||
| {
|
||||
requestId: string
|
||||
type: 'status'
|
||||
|
||||
@@ -218,7 +218,7 @@ export async function verifyPublishedRuntimeBundle(
|
||||
'Published Runtime entrypoint metadata changed'
|
||||
)
|
||||
}
|
||||
await assertElfArchitecture(
|
||||
if (manifest.provider === 'opencode') await assertElfArchitecture(
|
||||
executablePath,
|
||||
options.architecture,
|
||||
'Runtime executable',
|
||||
@@ -414,13 +414,15 @@ export function assertRuntimeManifestMatchesLock(
|
||||
lock: RemoteRuntimeLock,
|
||||
architecture: AgentArchitecture
|
||||
): void {
|
||||
const expected = lock.runtimes.opencode
|
||||
const expected = manifest.runtimeId === 'opencode' ? lock.runtimes.opencode
|
||||
: manifest.runtimeId === 'continue' ? lock.runtimes.continue : undefined
|
||||
if (!expected) throw new Error('Runtime manifest does not match a locked profile')
|
||||
const target = manifest.platform === 'darwin'
|
||||
? expected.targets['darwin-arm64']
|
||||
: expected.targets[architecture]
|
||||
if (
|
||||
target === undefined ||
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.runtimeId !== expected.provider ||
|
||||
manifest.provider !== expected.provider ||
|
||||
manifest.runtimeVersion !== expected.version ||
|
||||
manifest.architecture !== architecture ||
|
||||
@@ -725,4 +727,4 @@ async function assertElfArchitecture(
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ export const remoteRuntimeProviderKindSchema = z.enum([
|
||||
'continue'
|
||||
])
|
||||
|
||||
export const managedRemoteRuntimeIdSchema = z.enum(['opencode', 'continue'])
|
||||
export type ManagedRemoteRuntimeId = z.infer<typeof managedRemoteRuntimeIdSchema>
|
||||
|
||||
export const remoteRuntimeArchitectureSchema = agentArchitectureSchema
|
||||
|
||||
export const remoteRuntimeAllowedEnvironmentNameSchema = z.enum([
|
||||
@@ -80,7 +83,7 @@ const npmPackageIntegritySchema = z
|
||||
|
||||
export const remoteRuntimeSourcePackageSchema = z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,127}$/u),
|
||||
name: z.string().regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]{0,127}$/u),
|
||||
integrity: npmPackageIntegritySchema
|
||||
})
|
||||
.strict()
|
||||
@@ -262,7 +265,21 @@ export const remoteRuntimeLockSchema = z
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
.strict(),
|
||||
continue: z.object({
|
||||
version: componentVersionSchema,
|
||||
provider: z.literal('continue'),
|
||||
entrypoint: z.literal('lib/continue/dist/cn.js'),
|
||||
entrypointIdentity: z.literal('continue-acp'),
|
||||
argvPrefix: z.tuple([]),
|
||||
allowedEnvironmentNames: z.array(remoteRuntimeAllowedEnvironmentNameSchema),
|
||||
protocol: agentProtocolVersionSchema,
|
||||
targets: z.object({
|
||||
x64: remoteRuntimeLockedTargetSchema,
|
||||
arm64: remoteRuntimeLockedTargetSchema,
|
||||
'darwin-arm64': remoteRuntimeLockedTargetSchema.optional()
|
||||
}).strict()
|
||||
}).strict().optional()
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseContinueChecklist, runtimeChecklistSchema } from './runtime-checklist'
|
||||
|
||||
describe('native runtime checklist', () => {
|
||||
it('preserves long lists, duplicate items and explicit clears', () => {
|
||||
const content = 'task'.repeat(1500)
|
||||
expect(parseContinueChecklist('Checklist', { checklist: `- [ ] ${content}\n- [X] ${content}` })).toEqual({
|
||||
source: 'continue', items: [{ content, status: 'pending' }, { content, status: 'completed' }]
|
||||
})
|
||||
expect(runtimeChecklistSchema.parse(parseContinueChecklist('Checklist', '{"checklist":""}'))).toEqual({ source: 'continue', items: [] })
|
||||
})
|
||||
it('rejects unrelated tools and malformed lists without clearing', () => {
|
||||
for (const input of [{}, { checklist: [] }, { checklist: '- [ ] valid\ninvalid' }, { checklist: '- [ ] ' }, '{']) {
|
||||
expect(parseContinueChecklist('Checklist', input)).toBeUndefined()
|
||||
}
|
||||
expect(parseContinueChecklist('checklist', { checklist: '' })).toBeUndefined()
|
||||
expect(runtimeChecklistSchema.safeParse({ source: 'opencode', items: [{ content: 'x', status: 'done' }] }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const runtimeChecklistSchema = z.object({
|
||||
source: z.enum(['opencode', 'continue']),
|
||||
items: z.array(z.object({
|
||||
content: z.string().trim().min(1),
|
||||
status: z.enum(['pending', 'in_progress', 'completed', 'cancelled']),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional()
|
||||
}).strict())
|
||||
}).strict()
|
||||
|
||||
export type RuntimeChecklist = z.infer<typeof runtimeChecklistSchema>
|
||||
|
||||
// Only the native Checklist argument uses this syntax. Invalid input is not a clear.
|
||||
export function parseContinueChecklist(name: string, input: unknown): RuntimeChecklist | undefined {
|
||||
if (name !== 'Checklist') return undefined
|
||||
try {
|
||||
const args = typeof input === 'string' ? JSON.parse(input) : input
|
||||
if (!args || typeof args.checklist !== 'string') return undefined
|
||||
const items: RuntimeChecklist['items'] = []
|
||||
for (const line of args.checklist.split(/\r?\n/u)) {
|
||||
if (!line.trim()) continue
|
||||
const match = /^\s*- \[([ xX])\]\s+(.+?)\s*$/u.exec(line)
|
||||
if (!match || !match[2]?.trim()) return undefined
|
||||
items.push({ content: match[2]!, status: match[1] === ' ' ? 'pending' : 'completed' })
|
||||
}
|
||||
return { source: 'continue', items }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -312,8 +312,8 @@ export const sshHostRemoteEnvironmentSchema = z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
runtimeId: z.literal('opencode'),
|
||||
provider: z.literal('opencode'),
|
||||
runtimeId: z.enum(['opencode', 'continue']),
|
||||
provider: z.enum(['opencode', 'continue']),
|
||||
state: sshHostRemoteEnvironmentStateSchema,
|
||||
expected: sshHostRemoteEnvironmentVersionSchema,
|
||||
installed:
|
||||
|
||||
Reference in New Issue
Block a user