feat(acp-next): add event routing (#29327)

This commit is contained in:
Shoubhit Dash
2026-05-26 12:17:20 +05:30
committed by GitHub
parent 245f00a4b3
commit 7e5305c765
5 changed files with 552 additions and 30 deletions

View File

@@ -0,0 +1,190 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type {
Event,
EventMessagePartDelta,
EventMessagePartUpdated,
OpencodeClient,
Part,
SessionMessageResponse,
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { ACPNextSession } from "./session"
const log = Log.create({ service: "acp-next-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate">
type GlobalEventEnvelope = {
payload?: Event
}
type GlobalEventStream = {
stream: AsyncIterable<GlobalEventEnvelope>
}
export function start(input: {
sdk: OpencodeClient
connection: Connection
session: ACPNextSession.Interface
}) {
const subscription = new Subscription(input)
subscription.start()
return subscription
}
export class Subscription {
private readonly abort = new AbortController()
private started = false
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPNextSession.Interface
},
) {}
start() {
if (this.started) return
this.started = true
this.run().catch((error: unknown) => {
if (this.abort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
stop() {
this.abort.abort()
}
async handle(event: Event) {
switch (event.type) {
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
return this.handlePartDelta(event)
}
}
private async run() {
while (!this.abort.signal.aborted) {
const events = (await this.input.sdk.global.event({
signal: this.abort.signal,
})) as GlobalEventStream
for await (const event of events.stream) {
if (this.abort.signal.aborted) return
if (!event.payload) continue
await this.handle(event.payload).catch((error: unknown) => {
log.error("failed to handle event", { error, type: event.payload?.type })
})
}
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
private async handlePartUpdated(event: EventMessagePartUpdated) {
const part = event.properties.part
const sessionId = part.sessionID || event.properties.sessionID
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
if (!session) return
await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId: session.id,
messageId: part.messageID,
partId: part.id,
partType: part.type,
role: part.type === "reasoning" ? "assistant" : undefined,
ignored: part.type === "text" ? part.ignored : undefined,
toolCallId: part.type === "tool" ? part.callID : undefined,
metadata: "metadata" in part ? part.metadata : undefined,
}),
)
}
private async handlePartDelta(event: EventMessagePartDelta) {
const props = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
if (!session) return
const known = await Effect.runPromise(
this.input.session.tryGetPartMetadata({
sessionId: session.id,
messageId: props.messageID,
partId: props.partID,
}),
)
const metadata =
known?.role && known.partType
? known
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
if (metadata?.role !== "assistant") return
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_message_chunk",
messageId: props.messageID,
content: {
type: "text",
text: props.delta,
},
},
})
return
}
if (metadata.partType === "reasoning" && props.field === "text") {
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: props.messageID,
content: {
type: "text",
text: props.delta,
},
},
})
}
}
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
const message = await this.input.sdk.session
.message(
{
sessionID: sessionId,
messageID: messageId,
directory: cwd,
},
{ throwOnError: true },
)
.then((response) => response.data)
.catch((error: unknown) => {
log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId })
return undefined
})
if (!message) return
const part = message.parts.find((item) => item.id === partId)
if (!part) return
return await this.recordFetchedPart(sessionId, message, part)
}
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
return await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId,
messageId: part.messageID,
partId: part.id,
partType: part.type,
role: message.info.role,
ignored: part.type === "text" ? part.ignored : undefined,
toolCallId: part.type === "tool" ? part.callID : undefined,
metadata: "metadata" in part ? part.metadata : undefined,
}),
)
}
}
export * as ACPNextEvent from "./event"

View File

@@ -31,11 +31,12 @@ import {
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { Message, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { Directory } from "./directory"
import { ACPNextEvent } from "./event"
import { ACPNextSession } from "./session"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
@@ -71,10 +72,15 @@ export function make(input: {
connection?: Pick<AgentSideConnection, "sessionUpdate">
directory?: Directory.Interface
session?: ACPNextSession.Interface
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
if (input.connection) {
const subscription = ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
input.eventSubscription?.(subscription)
}
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const authMethod: AuthMethod = {
@@ -476,17 +482,13 @@ type SdkResponse<T> = {
}
type MessageInfo = {
readonly role?: string
readonly model?: {
readonly providerID?: string
readonly modelID?: string
readonly variant?: string
}
readonly providerID?: string
readonly modelID?: string
readonly variant?: string
readonly mode?: string
readonly agent?: string
readonly role?: Message["role"]
readonly model?: Extract<Message, { role: "user" }>["model"]
readonly providerID?: Extract<Message, { role: "assistant" }>["providerID"]
readonly modelID?: Extract<Message, { role: "assistant" }>["modelID"]
readonly variant?: Extract<Message, { role: "assistant" }>["variant"]
readonly mode?: Extract<Message, { role: "assistant" }>["mode"]
readonly agent?: Message["agent"]
}
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {

View File

@@ -1,4 +1,5 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, Ref } from "effect"
import type { ModelID, ProviderID } from "../provider/schema"
import * as ACPNextError from "./error"
@@ -11,6 +12,9 @@ export type SelectedModel = {
export type KnownMessagePartMetadata = {
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
@@ -40,6 +44,9 @@ export type RecordPartMetadataInput = {
sessionId: string
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
@@ -146,6 +153,9 @@ export const layer = Layer.effect(
const metadata = {
messageId: input.messageId,
partId: input.partId,
partType: input.partType,
role: input.role,
ignored: input.ignored,
toolCallId: input.toolCallId,
metadata: input.metadata,
}

View File

@@ -1,5 +1,6 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ModelID, ProviderID } from "@/provider/schema"
@@ -8,27 +9,14 @@ import { Context, Effect, Layer, SynchronizedRef } from "effect"
const log = Log.create({ service: "acp-next-usage" })
export type AssistantTokenCost = {
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: {
readonly read: number
readonly write: number
}
}
}
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
export type AssistantMessage = AssistantTokenCost & {
readonly role: "assistant"
readonly providerID?: string
readonly modelID?: string
}
export type AssistantMessage = AssistantTokenCost &
Pick<OpenCodeAssistantMessage, "role"> &
Partial<Pick<OpenCodeAssistantMessage, "providerID" | "modelID">>
export type SessionMessage = {
readonly info: { readonly role: string } | AssistantMessage
readonly info: { readonly role: Message["role"] } | AssistantMessage
}
export type MessagesInput = {