feat(acp): implement acp-next prompt (#29664)
This commit is contained in:
@@ -35,9 +35,11 @@ import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-
|
|||||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import * as ACPNextError from "./error"
|
import * as ACPNextError from "./error"
|
||||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||||
|
import { promptContentToParts } from "./content"
|
||||||
import { Directory } from "./directory"
|
import { Directory } from "./directory"
|
||||||
import { ACPNextEvent } from "./event"
|
import { ACPNextEvent } from "./event"
|
||||||
import { ACPNextSession } from "./session"
|
import { ACPNextSession } from "./session"
|
||||||
|
import { UsageService } from "./usage"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import type { Command } from "@/command"
|
import type { Command } from "@/command"
|
||||||
@@ -46,6 +48,8 @@ export const AuthMethodID = "opencode-login"
|
|||||||
const log = Log.create({ service: "acp-next-service" })
|
const log = Log.create({ service: "acp-next-service" })
|
||||||
|
|
||||||
export type Error = ACPNextError.Error
|
export type Error = ACPNextError.Error
|
||||||
|
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||||
|
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||||
|
|
||||||
export type Interface = {
|
export type Interface = {
|
||||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
|
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
|
||||||
@@ -69,10 +73,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
|
|||||||
|
|
||||||
export function make(input: {
|
export function make(input: {
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
connection?: Pick<AgentSideConnection, "sessionUpdate"> &
|
connection?: ServiceConnection
|
||||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
|
||||||
directory?: Directory.Interface
|
directory?: Directory.Interface
|
||||||
session?: ACPNextSession.Interface
|
session?: ACPNextSession.Interface
|
||||||
|
usage?: UsageService.Interface
|
||||||
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
|
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
|
||||||
}): Interface {
|
}): Interface {
|
||||||
const session = input.session ?? makeSessionService()
|
const session = input.session ?? makeSessionService()
|
||||||
@@ -444,8 +448,81 @@ export function make(input: {
|
|||||||
setSessionConfigOption,
|
setSessionConfigOption,
|
||||||
setSessionMode,
|
setSessionMode,
|
||||||
setSessionModel,
|
setSessionModel,
|
||||||
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
|
prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
|
||||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
|
const current = yield* session.get(params.sessionId)
|
||||||
|
const snapshot = yield* directorySnapshot(current.cwd)
|
||||||
|
const selected = current.model ?? selectDefaultModel(snapshot)
|
||||||
|
if (!current.model) {
|
||||||
|
yield* session.setModel(params.sessionId, selected)
|
||||||
|
}
|
||||||
|
const variant = current.variant ?? selectVariant(snapshot, selected)
|
||||||
|
const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined)
|
||||||
|
const parts = promptContentToParts(params.prompt)
|
||||||
|
const command = detectSlashCommand(parts)
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
const response = yield* request(
|
||||||
|
() =>
|
||||||
|
input.sdk.session.prompt(
|
||||||
|
{
|
||||||
|
sessionID: current.id,
|
||||||
|
model: {
|
||||||
|
providerID: selected.providerID,
|
||||||
|
modelID: selected.modelID,
|
||||||
|
},
|
||||||
|
...(variant ? { variant } : {}),
|
||||||
|
parts,
|
||||||
|
...(modeId ? { agent: modeId } : {}),
|
||||||
|
directory: current.cwd,
|
||||||
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
),
|
||||||
|
"session",
|
||||||
|
)
|
||||||
|
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||||
|
return promptResponse(response.info, params.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = snapshot.availableCommands.find((item) => item.name === command.name)
|
||||||
|
if (known) {
|
||||||
|
const response = yield* request(
|
||||||
|
() =>
|
||||||
|
input.sdk.session.command(
|
||||||
|
{
|
||||||
|
sessionID: current.id,
|
||||||
|
command: known.name,
|
||||||
|
arguments: command.args,
|
||||||
|
model: `${selected.providerID}/${selected.modelID}`,
|
||||||
|
...(variant ? { variant } : {}),
|
||||||
|
...(modeId ? { agent: modeId } : {}),
|
||||||
|
directory: current.cwd,
|
||||||
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
),
|
||||||
|
"session",
|
||||||
|
)
|
||||||
|
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||||
|
return promptResponse(response.info, params.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.name === "compact") {
|
||||||
|
yield* request(
|
||||||
|
() =>
|
||||||
|
input.sdk.session.summarize(
|
||||||
|
{
|
||||||
|
sessionID: current.id,
|
||||||
|
directory: current.cwd,
|
||||||
|
providerID: selected.providerID,
|
||||||
|
modelID: selected.modelID,
|
||||||
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
),
|
||||||
|
"session",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||||
|
return promptResponse(undefined, params.messageId)
|
||||||
}),
|
}),
|
||||||
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
|
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
|
||||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
|
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
|
||||||
@@ -474,6 +551,91 @@ function makeDirectoryService(sdk: OpencodeClient) {
|
|||||||
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
|
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeUsageService(sdk: OpencodeClient) {
|
||||||
|
const limits = new Map<string, Promise<number | undefined>>()
|
||||||
|
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(function* (
|
||||||
|
params,
|
||||||
|
) {
|
||||||
|
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
|
||||||
|
const current = limits.get(key)
|
||||||
|
if (current) return yield* Effect.promise(() => current)
|
||||||
|
|
||||||
|
const next = sdk.config
|
||||||
|
.providers({ directory: params.directory }, { throwOnError: true })
|
||||||
|
.then((response) => {
|
||||||
|
const providers = Object.fromEntries(
|
||||||
|
(response.data?.providers ?? []).map((provider) => [provider.id, provider]),
|
||||||
|
) as Record<ProviderID, Provider.Info>
|
||||||
|
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
log.error("failed to get providers for usage context limit", { error })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
limits.set(key, next)
|
||||||
|
return yield* Effect.promise(() => next)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(function* (
|
||||||
|
params,
|
||||||
|
) {
|
||||||
|
const messages = yield* request(
|
||||||
|
() =>
|
||||||
|
sdk.session.messages(
|
||||||
|
{
|
||||||
|
sessionID: params.sessionID,
|
||||||
|
directory: params.directory,
|
||||||
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
),
|
||||||
|
"session",
|
||||||
|
).pipe(
|
||||||
|
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.error("failed to fetch messages for usage update", { error })
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!messages) return
|
||||||
|
|
||||||
|
const message = UsageService.latestAssistantMessage(messages)
|
||||||
|
if (!message?.providerID || !message.modelID) return
|
||||||
|
|
||||||
|
const size = yield* contextLimit({
|
||||||
|
directory: params.directory,
|
||||||
|
providerID: ProviderID.make(message.providerID),
|
||||||
|
modelID: ModelID.make(message.modelID),
|
||||||
|
})
|
||||||
|
if (!size) return
|
||||||
|
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
params.connection
|
||||||
|
.sessionUpdate({
|
||||||
|
sessionId: params.sessionID,
|
||||||
|
update: {
|
||||||
|
sessionUpdate: "usage_update",
|
||||||
|
used: message.tokens.input + message.tokens.cache.read,
|
||||||
|
size,
|
||||||
|
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log.error("failed to send usage update", { error })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return UsageService.Service.of({
|
||||||
|
buildUsage: UsageService.buildUsage,
|
||||||
|
latestAssistantMessage: UsageService.latestAssistantMessage,
|
||||||
|
totalSessionCost: UsageService.totalSessionCost,
|
||||||
|
contextLimit,
|
||||||
|
sendUpdate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
|
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
|
||||||
if (!subscription) return Effect.void
|
if (!subscription) return Effect.void
|
||||||
return Effect.promise(async () => {
|
return Effect.promise(async () => {
|
||||||
@@ -506,6 +668,8 @@ type MessageInfo = {
|
|||||||
readonly agent?: Message["agent"]
|
readonly agent?: Message["agent"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AssistantInfo = UsageService.AssistantTokenCost | undefined
|
||||||
|
|
||||||
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||||
return Effect.tryPromise({
|
return Effect.tryPromise({
|
||||||
try: async () => {
|
try: async () => {
|
||||||
@@ -620,6 +784,43 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
|
|||||||
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
|
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
|
||||||
|
const text = parts
|
||||||
|
.filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("")
|
||||||
|
.trim()
|
||||||
|
if (!text.startsWith("/")) return
|
||||||
|
|
||||||
|
const [name, ...rest] = text.slice(1).split(/\s+/)
|
||||||
|
if (!name) return
|
||||||
|
return { name, args: rest.join(" ").trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
|
||||||
|
return {
|
||||||
|
stopReason: "end_turn",
|
||||||
|
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
||||||
|
...(messageId ? { userMessageId: messageId } : {}),
|
||||||
|
_meta: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendUsageUpdate(
|
||||||
|
usage: UsageService.Interface | undefined,
|
||||||
|
sdk: OpencodeClient,
|
||||||
|
connection: ServiceConnection | undefined,
|
||||||
|
sessionID: string,
|
||||||
|
directory: string,
|
||||||
|
) {
|
||||||
|
if (!connection) return Effect.void
|
||||||
|
return (usage ?? makeUsageService(sdk)).sendUpdate({
|
||||||
|
connection,
|
||||||
|
sessionID,
|
||||||
|
directory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
|
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
|
||||||
const variants = Directory.variants(snapshot, model)
|
const variants = Directory.variants(snapshot, model)
|
||||||
if (!variants) return
|
if (!variants) return
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
ForkSessionResponse,
|
ForkSessionResponse,
|
||||||
LoadSessionResponse,
|
LoadSessionResponse,
|
||||||
NewSessionResponse,
|
NewSessionResponse,
|
||||||
|
SessionNotification,
|
||||||
ResumeSessionResponse,
|
ResumeSessionResponse,
|
||||||
SessionConfigOption,
|
SessionConfigOption,
|
||||||
SessionConfigSelectOption,
|
SessionConfigSelectOption,
|
||||||
@@ -14,6 +15,7 @@ import { Effect, ManagedRuntime } from "effect"
|
|||||||
import * as ACPNextService from "@/acp-next/service"
|
import * as ACPNextService from "@/acp-next/service"
|
||||||
import * as ACPNextError from "@/acp-next/error"
|
import * as ACPNextError from "@/acp-next/error"
|
||||||
import { ACPNextSession } from "@/acp-next/session"
|
import { ACPNextSession } from "@/acp-next/session"
|
||||||
|
import { UsageService } from "@/acp-next/usage"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
|
|
||||||
@@ -141,10 +143,14 @@ const provider: Provider.Info = {
|
|||||||
|
|
||||||
describe("ACP next service sessions", () => {
|
describe("ACP next service sessions", () => {
|
||||||
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
|
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
|
||||||
const updates: unknown[] = []
|
const updates: SessionNotification[] = []
|
||||||
const mcpAdds: string[] = []
|
const mcpAdds: string[] = []
|
||||||
const aborts: string[] = []
|
const aborts: string[] = []
|
||||||
const forks: string[] = []
|
const forks: string[] = []
|
||||||
|
const prompts: unknown[] = []
|
||||||
|
const commands: unknown[] = []
|
||||||
|
const summarizes: unknown[] = []
|
||||||
|
const usageUpdates: string[] = []
|
||||||
const sessions = Array.from({ length: 102 }, (_, index) => ({
|
const sessions = Array.from({ length: 102 }, (_, index) => ({
|
||||||
id: `ses_${index + 1}`,
|
id: `ses_${index + 1}`,
|
||||||
directory: index % 2 === 0 ? "/workspace" : "/other",
|
directory: index % 2 === 0 ? "/workspace" : "/other",
|
||||||
@@ -184,6 +190,36 @@ describe("ACP next service sessions", () => {
|
|||||||
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
|
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
|
||||||
}),
|
}),
|
||||||
messages: () => Promise.resolve({ data: messages }),
|
messages: () => Promise.resolve({ data: messages }),
|
||||||
|
prompt: (input: unknown) => {
|
||||||
|
prompts.push(input)
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
info: assistantInfo({
|
||||||
|
input: 100,
|
||||||
|
output: 40,
|
||||||
|
reasoning: 7,
|
||||||
|
cache: { read: 11, write: 13 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
command: (input: unknown) => {
|
||||||
|
commands.push(input)
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
info: assistantInfo({
|
||||||
|
input: 3,
|
||||||
|
output: 4,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: { read: 0, write: 0 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
summarize: (input: unknown) => {
|
||||||
|
summarizes.push(input)
|
||||||
|
return Promise.resolve({ data: true })
|
||||||
|
},
|
||||||
abort: (input: { sessionID: string }) => {
|
abort: (input: { sessionID: string }) => {
|
||||||
aborts.push(input.sessionID)
|
aborts.push(input.sessionID)
|
||||||
return Promise.resolve({ data: true })
|
return Promise.resolve({ data: true })
|
||||||
@@ -201,13 +237,33 @@ describe("ACP next service sessions", () => {
|
|||||||
},
|
},
|
||||||
} as unknown as OpencodeClient
|
} as unknown as OpencodeClient
|
||||||
const connection = {
|
const connection = {
|
||||||
sessionUpdate: (update: unknown) => {
|
sessionUpdate: (update: SessionNotification) => {
|
||||||
updates.push(update)
|
updates.push(update)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
},
|
},
|
||||||
} as Pick<AgentSideConnection, "sessionUpdate">
|
} as Pick<AgentSideConnection, "sessionUpdate">
|
||||||
|
const usage = UsageService.Service.of({
|
||||||
|
buildUsage: UsageService.buildUsage,
|
||||||
|
latestAssistantMessage: UsageService.latestAssistantMessage,
|
||||||
|
totalSessionCost: UsageService.totalSessionCost,
|
||||||
|
contextLimit: () => Effect.succeed(128000),
|
||||||
|
sendUpdate: (input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
usageUpdates.push(input.sessionID)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks }
|
return {
|
||||||
|
service: ACPNextService.make({ sdk, connection, usage }),
|
||||||
|
updates,
|
||||||
|
mcpAdds,
|
||||||
|
aborts,
|
||||||
|
forks,
|
||||||
|
prompts,
|
||||||
|
commands,
|
||||||
|
summarizes,
|
||||||
|
usageUpdates,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it("creates a backed session with config options and command update", async () => {
|
it("creates a backed session with config options and command update", async () => {
|
||||||
@@ -657,8 +713,220 @@ describe("ACP next service sessions", () => {
|
|||||||
expect(providersCalls).toBe(1)
|
expect(providersCalls).toBe(1)
|
||||||
expect(commandCalls).toBe(1)
|
expect(commandCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("normal text prompt sends model variant mode and converted parts", async () => {
|
||||||
|
const { service, prompts, usageUpdates } = makeService()
|
||||||
|
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
await Effect.runPromise(
|
||||||
|
service.setSessionConfigOption({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
configId: "effort",
|
||||||
|
value: "high",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Effect.runPromise(
|
||||||
|
service.setSessionConfigOption({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
configId: "mode",
|
||||||
|
value: "plan",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
service.prompt({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
messageId: "00000000-0000-4000-8000-000000000001",
|
||||||
|
prompt: [{ type: "text", text: "hello" }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prompts).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: session.sessionId,
|
||||||
|
model: { providerID, modelID },
|
||||||
|
variant: "high",
|
||||||
|
parts: [{ type: "text", text: "hello" }],
|
||||||
|
agent: "plan",
|
||||||
|
directory: "/workspace",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(result).toEqual({
|
||||||
|
stopReason: "end_turn",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 40,
|
||||||
|
thoughtTokens: 7,
|
||||||
|
cachedReadTokens: 11,
|
||||||
|
cachedWriteTokens: 13,
|
||||||
|
totalTokens: 171,
|
||||||
|
},
|
||||||
|
userMessageId: "00000000-0000-4000-8000-000000000001",
|
||||||
|
_meta: {},
|
||||||
|
})
|
||||||
|
expect(usageUpdates).toEqual([session.sessionId])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prompt maps assistant and user audience annotations", async () => {
|
||||||
|
const { service, prompts } = makeService()
|
||||||
|
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
service.prompt({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
prompt: [
|
||||||
|
{ type: "text", text: "assistant context", annotations: { audience: ["assistant"] } },
|
||||||
|
{ type: "text", text: "user context", annotations: { audience: ["user"] } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prompts).toContainEqual({
|
||||||
|
sessionID: session.sessionId,
|
||||||
|
model: { providerID, modelID },
|
||||||
|
variant: "default",
|
||||||
|
parts: [
|
||||||
|
{ type: "text", text: "assistant context", synthetic: true },
|
||||||
|
{ type: "text", text: "user context", ignored: true },
|
||||||
|
],
|
||||||
|
agent: "build",
|
||||||
|
directory: "/workspace",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prompt sends image and resource parts", async () => {
|
||||||
|
const { service, prompts } = makeService()
|
||||||
|
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
service.prompt({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
prompt: [
|
||||||
|
{ type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" },
|
||||||
|
{
|
||||||
|
type: "resource",
|
||||||
|
resource: {
|
||||||
|
uri: "file:///tmp/report.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
blob: "JVBERg==",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect((prompts[0] as { parts?: unknown }).parts).toEqual([
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
url: "data:image/png;base64,AAAA",
|
||||||
|
filename: "screenshot.png",
|
||||||
|
mime: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
url: "data:application/pdf;base64,JVBERg==",
|
||||||
|
filename: "report.pdf",
|
||||||
|
mime: "application/pdf",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("slash command prompt calls session command", async () => {
|
||||||
|
const { service, prompts, commands } = makeService()
|
||||||
|
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prompts).toEqual([])
|
||||||
|
expect(commands).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: session.sessionId,
|
||||||
|
command: "init",
|
||||||
|
arguments: "now",
|
||||||
|
model: "test/test-model",
|
||||||
|
variant: "default",
|
||||||
|
agent: "build",
|
||||||
|
directory: "/workspace",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("compact slash command calls summarize path", async () => {
|
||||||
|
const { service, prompts, commands, summarizes } = makeService()
|
||||||
|
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prompts).toEqual([])
|
||||||
|
expect(commands).toEqual([])
|
||||||
|
expect(summarizes).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: session.sessionId,
|
||||||
|
directory: "/workspace",
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("maps prompt auth failures to auth-required request errors", async () => {
|
||||||
|
const { service } = makeService()
|
||||||
|
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
const failing = ACPNextService.make({
|
||||||
|
sdk: {
|
||||||
|
config: {
|
||||||
|
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
|
||||||
|
get: () => Promise.resolve({ data: {} }),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
|
||||||
|
skills: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
create: () => Promise.resolve({ data: { id: session.sessionId } }),
|
||||||
|
list: () => Promise.resolve({ data: [] }),
|
||||||
|
prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
add: () => Promise.resolve({ data: {} }),
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient,
|
||||||
|
usage: UsageService.Service.of({
|
||||||
|
buildUsage: UsageService.buildUsage,
|
||||||
|
latestAssistantMessage: UsageService.latestAssistantMessage,
|
||||||
|
totalSessionCost: UsageService.totalSessionCost,
|
||||||
|
contextLimit: () => Effect.succeed(128000),
|
||||||
|
sendUpdate: () => Effect.void,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
const error = await Effect.runPromise(
|
||||||
|
failing
|
||||||
|
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
|
||||||
|
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.code).toBe(-32000)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
providerID: "test",
|
||||||
|
modelID: "test-model",
|
||||||
|
cost: 0,
|
||||||
|
tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function categories(result: NewSessionResponse | LoadSessionResponse) {
|
function categories(result: NewSessionResponse | LoadSessionResponse) {
|
||||||
return result.configOptions?.map((option) => option.category) ?? []
|
return result.configOptions?.map((option) => option.category) ?? []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
InitializeResponse,
|
InitializeResponse,
|
||||||
LoadSessionResponse,
|
LoadSessionResponse,
|
||||||
NewSessionResponse,
|
NewSessionResponse,
|
||||||
|
PromptResponse,
|
||||||
ResumeSessionResponse,
|
ResumeSessionResponse,
|
||||||
SessionNotification,
|
SessionNotification,
|
||||||
SetSessionConfigOptionResponse,
|
SetSessionConfigOptionResponse,
|
||||||
@@ -95,11 +96,21 @@ describe("opencode acp-next (subprocess)", () => {
|
|||||||
)
|
)
|
||||||
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
|
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
|
||||||
|
|
||||||
const prompt = yield* acp.request("session/prompt", {
|
yield* llm.text("hello from acp-next", { usage: { input: 11, output: 7 } })
|
||||||
|
const prompted = expectOk(
|
||||||
|
yield* acp.request<PromptResponse>("session/prompt", {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
prompt: [{ type: "text", text: "hello" }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(prompted.stopReason).toBe("end_turn")
|
||||||
|
expect(prompted.usage?.totalTokens).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const missing = yield* acp.request("session/prompt", {
|
||||||
sessionId: "ses_missing",
|
sessionId: "ses_missing",
|
||||||
prompt: [{ type: "text", text: "hello" }],
|
prompt: [{ type: "text", text: "hello" }],
|
||||||
})
|
})
|
||||||
expect(errorCode(prompt.error)).toBe(-32601)
|
expect(errorCode(missing.error)).toBe(-32602)
|
||||||
}),
|
}),
|
||||||
60_000,
|
60_000,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user