mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-10 20:40:36 +00:00
feat: Update ACP support, modernize and fix misc issues (#25663)
This commit is contained in:
parent
233fc5b910
commit
b2e3dc87ea
5 changed files with 197 additions and 75 deletions
4
bun.lock
4
bun.lock
|
|
@ -335,7 +335,7 @@
|
|||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
|
|
@ -728,7 +728,7 @@
|
|||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
|
||||
|
||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@
|
|||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
type AuthenticateRequest,
|
||||
type AuthMethod,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type CloseSessionResponse,
|
||||
type ForkSessionRequest,
|
||||
type ForkSessionResponse,
|
||||
type InitializeRequest,
|
||||
|
|
@ -565,6 +567,7 @@ export class Agent implements ACPAgent {
|
|||
image: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
close: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
|
|
@ -627,6 +630,9 @@ export class Agent implements ACPAgent {
|
|||
// Store ACP session state
|
||||
await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
|
||||
|
||||
const result = await this.loadSessionMode({
|
||||
|
|
@ -635,39 +641,6 @@ export class Agent implements ACPAgent {
|
|||
sessionId,
|
||||
})
|
||||
|
||||
// Replay session history
|
||||
const messages = await this.sdk.session
|
||||
.messages(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
log.error("unexpected error when fetching message", { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
|
||||
if (lastUser?.role === "user") {
|
||||
result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
|
||||
this.sessionManager.setModel(sessionId, {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
})
|
||||
if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) {
|
||||
result.modes.currentModeId = lastUser.agent
|
||||
this.sessionManager.setMode(sessionId, lastUser.agent)
|
||||
}
|
||||
result.configOptions = buildConfigOptions({
|
||||
currentModelId: result.models.currentModelId,
|
||||
availableModels: result.models.availableModels,
|
||||
modes: result.modes,
|
||||
})
|
||||
}
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
log.debug("replay message", msg)
|
||||
await this.processMessage(msg)
|
||||
|
|
@ -756,6 +729,9 @@ export class Agent implements ACPAgent {
|
|||
const sessionId = forked.id
|
||||
await this.sessionManager.load(sessionId, directory, mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
|
||||
|
||||
const mode = await this.loadSessionMode({
|
||||
|
|
@ -764,20 +740,6 @@ export class Agent implements ACPAgent {
|
|||
sessionId,
|
||||
})
|
||||
|
||||
const messages = await this.sdk.session
|
||||
.messages(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
log.error("unexpected error when fetching message", { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
log.debug("replay message", msg)
|
||||
await this.processMessage(msg)
|
||||
|
|
@ -797,7 +759,7 @@ export class Agent implements ACPAgent {
|
|||
}
|
||||
}
|
||||
|
||||
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
const directory = params.cwd
|
||||
const sessionId = params.sessionId
|
||||
const mcpServers = params.mcpServers ?? []
|
||||
|
|
@ -806,6 +768,9 @@ export class Agent implements ACPAgent {
|
|||
const model = await defaultModel(this.config, directory)
|
||||
await this.sessionManager.load(sessionId, directory, mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId, 20)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
|
||||
|
||||
const result = await this.loadSessionMode({
|
||||
|
|
@ -828,6 +793,27 @@ export class Agent implements ACPAgent {
|
|||
}
|
||||
}
|
||||
|
||||
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessionManager.remove(params.sessionId)
|
||||
if (!session) return {}
|
||||
|
||||
await this.sdk.session
|
||||
.abort(
|
||||
{
|
||||
sessionID: params.sessionId,
|
||||
directory: session.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.catch((error) => {
|
||||
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
|
||||
})
|
||||
|
||||
this.permissionQueues.delete(params.sessionId)
|
||||
log.info("close_session", { sessionId: params.sessionId })
|
||||
return {}
|
||||
}
|
||||
|
||||
private async processMessage(message: SessionMessageResponse) {
|
||||
log.debug("process message", message)
|
||||
if (message.info.role !== "assistant" && message.info.role !== "user") return
|
||||
|
|
@ -1159,23 +1145,26 @@ export class Agent implements ACPAgent {
|
|||
sessionId: string,
|
||||
): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
|
||||
const availableModes = await this.loadAvailableModes(directory)
|
||||
const currentModeId =
|
||||
this.sessionManager.get(sessionId).modeId ||
|
||||
(await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
})())
|
||||
const storedModeId = this.sessionManager.get(sessionId).modeId
|
||||
if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) {
|
||||
return { availableModes, currentModeId: storedModeId }
|
||||
}
|
||||
|
||||
const currentModeId = await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
})()
|
||||
|
||||
return { availableModes, currentModeId }
|
||||
}
|
||||
|
||||
private async loadSessionMode(params: LoadSessionRequest) {
|
||||
const directory = params.cwd
|
||||
const model = await defaultModel(this.config, directory)
|
||||
const sessionId = params.sessionId
|
||||
const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory))
|
||||
|
||||
const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
|
||||
const entries = sortProvidersByName(providers)
|
||||
|
|
@ -1184,7 +1173,7 @@ export class Agent implements ACPAgent {
|
|||
if (currentVariant && !availableVariants.includes(currentVariant)) {
|
||||
this.sessionManager.setVariant(sessionId, undefined)
|
||||
}
|
||||
const availableModels = buildAvailableModels(entries, { includeVariants: true })
|
||||
const availableModels = buildAvailableModels(entries)
|
||||
const modeState = await this.resolveModeState(directory, sessionId)
|
||||
const currentModeId = modeState.currentModeId
|
||||
const modes = currentModeId
|
||||
|
|
@ -1267,13 +1256,15 @@ export class Agent implements ACPAgent {
|
|||
return {
|
||||
sessionId,
|
||||
models: {
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
|
||||
availableModels,
|
||||
},
|
||||
modes,
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
|
||||
availableModels,
|
||||
currentVariant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
_meta: buildVariantMeta({
|
||||
|
|
@ -1296,6 +1287,24 @@ export class Agent implements ACPAgent {
|
|||
|
||||
const entries = sortProvidersByName(providers)
|
||||
const availableVariants = modelVariantsFromProviders(entries, selection.model)
|
||||
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||
const modes = modeState.currentModeId
|
||||
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||
: undefined
|
||||
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false),
|
||||
availableModels: buildAvailableModels(entries),
|
||||
currentVariant: selection.variant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
_meta: buildVariantMeta({
|
||||
|
|
@ -1327,6 +1336,14 @@ export class Agent implements ACPAgent {
|
|||
const selection = parseModelSelection(params.value, providers)
|
||||
this.sessionManager.setModel(session.id, selection.model)
|
||||
this.sessionManager.setVariant(session.id, selection.variant)
|
||||
} else if (params.configId === "effort") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string")
|
||||
const current = session.model ?? (await defaultModel(this.config, session.cwd))
|
||||
const availableVariants = modelVariantsFromProviders(entries, current)
|
||||
if (!availableVariants.includes(params.value)) {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` }))
|
||||
}
|
||||
this.sessionManager.setVariant(session.id, params.value)
|
||||
} else if (params.configId === "mode") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
|
||||
const availableModes = await this.loadAvailableModes(session.cwd)
|
||||
|
|
@ -1341,15 +1358,21 @@ export class Agent implements ACPAgent {
|
|||
const updatedSession = this.sessionManager.get(session.id)
|
||||
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
|
||||
const availableVariants = modelVariantsFromProviders(entries, model)
|
||||
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
|
||||
const availableModels = buildAvailableModels(entries, { includeVariants: true })
|
||||
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false)
|
||||
const availableModels = buildAvailableModels(entries)
|
||||
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||
const modes = modeState.currentModeId
|
||||
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||
: undefined
|
||||
|
||||
return {
|
||||
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId,
|
||||
availableModels,
|
||||
currentVariant: updatedSession.variant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1546,6 +1569,37 @@ export class Agent implements ACPAgent {
|
|||
{ throwOnError: true },
|
||||
)
|
||||
}
|
||||
|
||||
private async loadSessionMessages(directory: string, sessionId: string, limit?: number) {
|
||||
return this.sdk.session
|
||||
.messages(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
limit,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
log.error("unexpected error when fetching message", { error })
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) {
|
||||
const lastUser = messages?.findLast((message) => message.info.role === "user")?.info
|
||||
if (lastUser?.role !== "user") return
|
||||
|
||||
this.sessionManager.setModel(sessionId, {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
})
|
||||
this.sessionManager.setVariant(sessionId, lastUser.model.variant)
|
||||
if (lastUser.agent) {
|
||||
this.sessionManager.setMode(sessionId, lastUser.agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
|
|
@ -1629,11 +1683,11 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
|
|||
|
||||
if (specified && !providers.length) return specified
|
||||
|
||||
const lastUsed = await lastUsedModel(sdk, directory, providers)
|
||||
if (lastUsed) return lastUsed
|
||||
|
||||
const opencodeProvider = providers.find((p) => p.id === "opencode")
|
||||
if (opencodeProvider) {
|
||||
if (opencodeProvider.models["big-pickle"]) {
|
||||
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
|
||||
}
|
||||
const [best] = Provider.sort(Object.values(opencodeProvider.models))
|
||||
if (best) {
|
||||
return {
|
||||
|
|
@ -1653,8 +1707,38 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
|
|||
}
|
||||
|
||||
if (specified) return specified
|
||||
throw new Error("No models available")
|
||||
}
|
||||
|
||||
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
|
||||
async function lastUsedModel(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
providers: Array<{ id: string; models: Record<string, unknown> }>,
|
||||
): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> {
|
||||
const session = await sdk.session
|
||||
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
|
||||
.then((x) => x.data?.[0])
|
||||
.catch((error) => {
|
||||
log.error("failed to list sessions for default model", { error })
|
||||
return undefined
|
||||
})
|
||||
if (!session) return
|
||||
|
||||
const lastUser = await sdk.session
|
||||
.messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true })
|
||||
.then((x) => x.data?.findLast((message) => message.info.role === "user")?.info)
|
||||
.catch((error) => {
|
||||
log.error("failed to load session messages for default model", { error, sessionID: session.id })
|
||||
return undefined
|
||||
})
|
||||
if (lastUser?.role !== "user") return
|
||||
|
||||
const provider = providers.find((entry) => entry.id === lastUser.model.providerID)
|
||||
if (!provider?.models[lastUser.model.modelID]) return
|
||||
return {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
}
|
||||
}
|
||||
|
||||
function parseUri(
|
||||
|
|
@ -1757,8 +1841,14 @@ function formatModelIdWithVariant(
|
|||
includeVariant: boolean,
|
||||
) {
|
||||
const base = `${model.providerID}/${model.modelID}`
|
||||
if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
|
||||
return `${base}/${variant}`
|
||||
if (!includeVariant || availableVariants.length === 0) return base
|
||||
const selectedVariant =
|
||||
variant && availableVariants.includes(variant)
|
||||
? variant
|
||||
: availableVariants.includes(DEFAULT_VARIANT_VALUE)
|
||||
? DEFAULT_VARIANT_VALUE
|
||||
: availableVariants[0]
|
||||
return `${base}/${selectedVariant}`
|
||||
}
|
||||
|
||||
function buildVariantMeta(input: {
|
||||
|
|
@ -1810,6 +1900,8 @@ function parseModelSelection(
|
|||
function buildConfigOptions(input: {
|
||||
currentModelId: string
|
||||
availableModels: ModelOption[]
|
||||
currentVariant?: string
|
||||
availableVariants?: string[]
|
||||
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
|
||||
}): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [
|
||||
|
|
@ -1822,6 +1914,22 @@ function buildConfigOptions(input: {
|
|||
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
|
||||
},
|
||||
]
|
||||
if (input.availableVariants?.length) {
|
||||
options.push({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue:
|
||||
input.currentVariant && input.availableVariants.includes(input.currentVariant)
|
||||
? input.currentVariant
|
||||
: input.availableVariants.includes(DEFAULT_VARIANT_VALUE)
|
||||
? DEFAULT_VARIANT_VALUE
|
||||
: input.availableVariants[0],
|
||||
options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
|
||||
})
|
||||
}
|
||||
if (input.modes) {
|
||||
options.push({
|
||||
id: "mode",
|
||||
|
|
@ -1839,4 +1947,11 @@ function buildConfigOptions(input: {
|
|||
return options
|
||||
}
|
||||
|
||||
function formatVariantName(variant: string) {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export * as ACP from "./agent"
|
||||
|
|
|
|||
|
|
@ -113,4 +113,10 @@ export class ACPSessionManager {
|
|||
this.sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
|
||||
remove(sessionId: string): ACPSessionState | undefined {
|
||||
const session = this.sessions.get(sessionId)
|
||||
this.sessions.delete(sessionId)
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,11 @@ describe("acp.agent interface compliance", () => {
|
|||
"loadSession",
|
||||
"setSessionMode",
|
||||
"authenticate",
|
||||
// Unstable - SDK checks these with unstable_ prefix
|
||||
// Capability-gated methods checked by the SDK router
|
||||
"listSessions",
|
||||
"resumeSession",
|
||||
"closeSession",
|
||||
"unstable_forkSession",
|
||||
"unstable_resumeSession",
|
||||
"unstable_setSessionModel",
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue