mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-03 14:06:29 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dcae28cb3 | ||
|
|
56e4c24fd4 | ||
|
|
ef784e57a2 | ||
|
|
d113a40f55 | ||
|
|
54956aac22 | ||
|
|
c1536734ce | ||
|
|
cf7fcfad0a | ||
|
|
17955a1100 | ||
|
|
c0c9c69a27 | ||
|
|
eb1a9ea4d9 | ||
|
|
680bca3183 | ||
|
|
22f81eed0b | ||
|
|
421a04f5fd | ||
|
|
ec541abbcd | ||
|
|
4c63a35dbe | ||
|
|
0e4dd7bfe9 | ||
|
|
e28a624b8b | ||
|
|
9277761af6 | ||
|
|
0bef0eab67 | ||
|
|
66d15a7352 | ||
|
|
9f8551ab6b | ||
|
|
8bcda4c718 | ||
|
|
ccd2aca07a | ||
|
|
1391c0d52c | ||
|
|
ec90ad50d3 | ||
|
|
2a01ed60b1 | ||
|
|
80caae8ad1 |
No files matched your search
@@ -2,9 +2,9 @@ name: nix-eval
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, v2]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
branches: [dev, v2]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ProviderInternalError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type CacheHint,
|
||||
type LLMRequest,
|
||||
@@ -46,12 +45,6 @@ const OpenAIChatCacheControl = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatCacheControl = Schema.Schema.Type<typeof OpenAIChatCacheControl>
|
||||
|
||||
const OpenAIChatFunction = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: JsonObject,
|
||||
})
|
||||
|
||||
const OpenAIChatTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
function: Schema.Struct({
|
||||
|
||||
@@ -14,7 +14,6 @@ import * as GoogleVertexChat from "../src/providers/google-vertex-chat.js"
|
||||
import * as GoogleVertexMessages from "../src/providers/google-vertex-messages.js"
|
||||
import * as GoogleVertexResponses from "../src/providers/google-vertex-responses.js"
|
||||
import * as OpenAI from "../src/providers/openai.js"
|
||||
import * as OpenAICompatible from "../src/providers/openai-compatible.js"
|
||||
import * as OpenRouter from "../src/providers/openrouter.js"
|
||||
import * as XAI from "../src/providers/xai.js"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": false,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
mapVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
|
||||
@@ -242,7 +242,7 @@ test("keeps an older answer selected while scrolling within the interaction buff
|
||||
|
||||
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_descendant_keyboard_01_shell"
|
||||
const timeline = await setupTimeline(page, {
|
||||
await setupTimeline(page, {
|
||||
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import path from "node:path"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
|
||||
|
||||
@@ -30,14 +30,7 @@ import type { Upload } from "../attachments/uploads"
|
||||
import { CommentCard } from "@opencode/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode/session-ui/message-file"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
ComposerComment,
|
||||
ComposerOption,
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import type { ComposerAttachment, ComposerComment, ComposerOption, ComposerPrompt, ComposerSuggestion } from "../types"
|
||||
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
import "../attachments/attachments.css"
|
||||
|
||||
@@ -35,7 +35,6 @@ import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { CustomProviderForm } from "@/providers/credentials/dialog"
|
||||
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Component, Show, createMemo, createResource } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { TimelineDetailControl } from "@/settings/timeline-detail"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
@@ -14,42 +13,12 @@ import {
|
||||
useSettings,
|
||||
} from "@/settings/model"
|
||||
import { formatKeybind } from "@/shell/commands/command"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import {
|
||||
createAppearanceSettingsController,
|
||||
createShellOptions,
|
||||
type AppearanceSettingsController,
|
||||
type ShellSettingsController,
|
||||
} from "./controllers"
|
||||
import { createShellOptions, type ShellSettingsController } from "./controllers"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
const fontSettings = {
|
||||
ui: {
|
||||
action: "settings-ui-font",
|
||||
title: "settings.general.row.uiFont.title",
|
||||
description: "settings.general.row.uiFont.description",
|
||||
font: "ui",
|
||||
input: "setUI",
|
||||
},
|
||||
code: {
|
||||
action: "settings-code-font",
|
||||
title: "settings.general.row.font.title",
|
||||
description: "settings.general.row.font.description",
|
||||
font: "code",
|
||||
input: "setCode",
|
||||
},
|
||||
terminal: {
|
||||
action: "settings-terminal-font",
|
||||
title: "settings.general.row.terminalFont.title",
|
||||
description: "settings.general.row.terminalFont.description",
|
||||
font: "terminal",
|
||||
input: "setTerminal",
|
||||
},
|
||||
} as const
|
||||
const AutoApprovePermissionsSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -183,90 +152,6 @@ const FollowUpBehaviorSetting: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.appearance")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-color-scheme"
|
||||
options={schemeOptions}
|
||||
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) => {
|
||||
if (option === "system") return language.t("theme.scheme.system")
|
||||
if (option === "light") return language.t("theme.scheme.light")
|
||||
return language.t("theme.scheme.dark")
|
||||
}}
|
||||
onSelect={(option) => option && props.controller.scheme.select(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<ExternalLink class="settings-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</ExternalLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-theme"
|
||||
options={props.controller.theme.options()}
|
||||
current={props.controller.theme.current()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.name}
|
||||
onSelect={props.controller.theme.select}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<FontSetting kind="ui" fonts={props.controller.fonts} />
|
||||
<FontSetting kind="code" fonts={props.controller.fonts} />
|
||||
<FontSetting kind="terminal" fonts={props.controller.fonts} />
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FontSetting: Component<{
|
||||
kind: "ui" | "code" | "terminal"
|
||||
fonts: AppearanceSettingsController["fonts"]
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const config = () => fontSettings[props.kind]
|
||||
return (
|
||||
<SettingsRow title={language.t(config().title)} description={language.t(config().description)}>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInput
|
||||
data-action={config().action}
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={props.fonts[config().font]().value}
|
||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
||||
placeholder={props.fonts[config().font]().placeholder}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t(config().title)}
|
||||
style={{ "font-family": props.fonts[config().font]().family }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const LanguageSetting = () => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
@@ -409,50 +294,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
|
||||
@@ -15,7 +15,6 @@ import { displayName } from "@opencode/ui/project-avatar"
|
||||
import { resolveProjectForSession } from "@/shell/layout/helpers"
|
||||
import { useExtensionHost } from "@/runtime/extension/host"
|
||||
import { useExtensionAttachment } from "@/runtime/extension/services"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { looksLikeSessionID } from "@/session/search"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { batch, createEffect, onCleanup } from "solid-js"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { Data } from "@opencode/client/solid"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { playSoundById } from "@/shell/notifications/sound"
|
||||
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
import { requireServerKey, sessionHref } from "@/shell/routes/session"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
const NotificationBase = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/runti
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useExtensionServers } from "@/runtime/extension/servers"
|
||||
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
|
||||
@@ -80,7 +80,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), TabStorage.Closed, [])
|
||||
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName } from "@opencode/ui/project-avatar"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "node_modules/.ts-dist",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { Cause, Deferred, Effect, Ref, type Scope } from "effect"
|
||||
import { ACPCapabilities } from "./capabilities"
|
||||
import { ACPCatalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { ACPConnection } from "./connection"
|
||||
@@ -27,7 +28,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const ready = yield* Deferred.make<ACPService.Interface>()
|
||||
const handle =
|
||||
<Params, A>(
|
||||
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPService.Failure>,
|
||||
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Failure>,
|
||||
) =>
|
||||
(name: string) => {
|
||||
const handler = Effect.fn(name)(
|
||||
@@ -35,7 +36,8 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
|
||||
Effect.catchTags({
|
||||
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
|
||||
ACPCatalogNotReadyError: (error) => Effect.die(error),
|
||||
ACPCatalogNotReadyError: (error) =>
|
||||
Effect.fail(new ACPError.ServiceFailureError({ safeMessage: error.message, errorName: "CatalogNotReady" })),
|
||||
}),
|
||||
Effect.mapError((error) => (error instanceof RequestError ? error : ACPError.toRequestError(error))),
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
|
||||
@@ -113,7 +115,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const acp = ACPConnection.make(app, stream)
|
||||
const connection = acp.connection
|
||||
const sessions = yield* ACPSessions.make({ client, connection, catalog })
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false, compaction: false })
|
||||
const capabilities = yield* Ref.make(ACPCapabilities.parse(undefined))
|
||||
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
|
||||
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
|
||||
return acp.agent
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk"
|
||||
|
||||
export const ChildSessionUpdates = "opencode/child-session-updates"
|
||||
|
||||
export type Capabilities = ReturnType<typeof parse>
|
||||
|
||||
export function parse(client: ClientCapabilities | undefined) {
|
||||
const elicitation = client?.elicitation
|
||||
const compaction = client?.session?.compaction
|
||||
return {
|
||||
childSessionUpdates: client?._meta?.[ChildSessionUpdates] === true,
|
||||
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
|
||||
compaction: compaction !== undefined && compaction !== null,
|
||||
}
|
||||
}
|
||||
|
||||
export * as ACPCapabilities from "./capabilities"
|
||||
@@ -3,7 +3,7 @@ import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Context, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
|
||||
export const builtinCommands = new Map([
|
||||
@@ -19,7 +19,7 @@ export type Catalog = {
|
||||
readonly commands: ReadonlyArray<Command.Info>
|
||||
}
|
||||
|
||||
export class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
|
||||
class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
|
||||
reason: Schema.Literals(["models", "agents"]),
|
||||
}) {
|
||||
override get message() {
|
||||
@@ -27,7 +27,7 @@ export class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatal
|
||||
}
|
||||
}
|
||||
|
||||
export class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
|
||||
class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
@@ -39,14 +39,12 @@ export interface Interface {
|
||||
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Catalog") {}
|
||||
|
||||
type Entry = {
|
||||
readonly cwd: string
|
||||
readonly catalog: SubscriptionRef.SubscriptionRef<Catalog>
|
||||
readonly lock: Semaphore.Semaphore
|
||||
requested: number
|
||||
loaded: number
|
||||
requestedGeneration: number
|
||||
loadedGeneration: number
|
||||
}
|
||||
|
||||
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
|
||||
@@ -59,17 +57,17 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
// Requests queued behind a running load share the next one.
|
||||
const reload = (entry: Entry) =>
|
||||
Effect.suspend(() => {
|
||||
const target = ++entry.requested
|
||||
const target = ++entry.requestedGeneration
|
||||
return entry.lock.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (entry.loaded >= target) return Effect.void
|
||||
const generation = entry.requested
|
||||
if (entry.loadedGeneration >= target) return Effect.void
|
||||
const generation = entry.requestedGeneration
|
||||
return load(client, entry.cwd).pipe(
|
||||
Effect.flatMap((next) => SubscriptionRef.set(entry.catalog, next)),
|
||||
Effect.ignore,
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
entry.loaded = generation
|
||||
entry.loadedGeneration = generation
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -90,7 +88,9 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
Effect.ignore,
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP catalog event stream failed", cause),
|
||||
),
|
||||
Effect.ensuring(Deferred.succeed(connected, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
@@ -101,8 +101,8 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
cwd,
|
||||
catalog: yield* SubscriptionRef.make<Catalog>(yield* load(client, cwd)),
|
||||
lock: Semaphore.makeUnsafe(1),
|
||||
requested: 0,
|
||||
loaded: 0,
|
||||
requestedGeneration: 0,
|
||||
loadedGeneration: 0,
|
||||
} satisfies Entry
|
||||
})
|
||||
|
||||
@@ -123,8 +123,8 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("cli.acp.catalog.get")(function* (cwd) {
|
||||
return {
|
||||
get: Effect.fnUntraced(function* (cwd) {
|
||||
const loaded = yield* entry(cwd)
|
||||
return yield* SubscriptionRef.get(loaded.catalog)
|
||||
}),
|
||||
@@ -132,7 +132,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
yield* reload(yield* entry(cwd))
|
||||
}),
|
||||
changes: (cwd) => Stream.unwrap(entry(cwd).pipe(Effect.map((loaded) => SubscriptionRef.changes(loaded.catalog)))),
|
||||
})
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
const load = (client: OpenCodeClient, cwd: string) =>
|
||||
@@ -181,7 +181,7 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
} satisfies Catalog
|
||||
})
|
||||
|
||||
function providers(models: ReadonlyArray<Model.Info>): ConfigOptionProvider[] {
|
||||
function providers(models: ReadonlyArray<Model.Info>) {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
|
||||
export const UpdateMethod = "opencode/session/child_update"
|
||||
|
||||
export type Session = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
export type Update = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
} & Event
|
||||
|
||||
export function update(rootSessionID: string, child: Session, event: Event): Update {
|
||||
return {
|
||||
rootSessionId: rootSessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...event,
|
||||
}
|
||||
}
|
||||
|
||||
export function project(sessionUpdate: SessionUpdate, child: Session) {
|
||||
const projected = { ...sessionUpdate }
|
||||
projected._meta = { ...projected._meta, ...meta(child) }
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = toolCallID(child, projected.toolCallId)
|
||||
if (projected.title) projected.title = prefixTitle(child, projected.title)
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
export function meta(child: Session) {
|
||||
return {
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function toolCallID(child: Session | undefined, id: string) {
|
||||
return child ? `${child.id}:${id}` : id
|
||||
}
|
||||
|
||||
export function prefixTitle(child: Session | undefined, value: string) {
|
||||
return child?.title ? `${child.title}: ${value}` : value
|
||||
}
|
||||
|
||||
export * as ACPChild from "./child"
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ClientError } from "@opencode/client/effect"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
|
||||
import { InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
@@ -9,23 +9,20 @@ export function classify(error: unknown): Effect.Effect<never, ACPError.Error> {
|
||||
if (
|
||||
error instanceof ClientError &&
|
||||
HttpClientError.isHttpClientError(error.cause) &&
|
||||
isConnectionFailure(error.cause)
|
||||
// The client reports a failed body read as a `DecodeError` with a cause; its other `DecodeError`s have none.
|
||||
(error.cause.reason._tag === "TransportError" ||
|
||||
(error.cause.reason._tag === "DecodeError" && error.cause.reason.cause !== undefined))
|
||||
)
|
||||
return Effect.fail(new ACPError.ServerUnavailableError())
|
||||
if (error instanceof SessionNotFoundError)
|
||||
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: error.sessionID }))
|
||||
if (error instanceof InvalidRequestError)
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: error.message, field: error.field }))
|
||||
if (error instanceof InvalidCursorError)
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: error.message, field: "cursor" }))
|
||||
return Effect.die(error)
|
||||
}
|
||||
|
||||
// The client reports a failed body read as a `DecodeError` with a cause; its other `DecodeError`s have none.
|
||||
function isConnectionFailure(error: HttpClientError.HttpClientError) {
|
||||
return (
|
||||
error.reason._tag === "TransportError" || (error.reason._tag === "DecodeError" && error.reason.cause !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
export function decodeSessionID(value: string) {
|
||||
return Schema.decodeUnknownEffect(Session.ID)(value).pipe(
|
||||
Effect.mapError(() => new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SessionMessage } from "@opencode/schema/session-message"
|
||||
|
||||
const MarkerMeta = "opencode/compaction"
|
||||
|
||||
export type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
|
||||
type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
|
||||
|
||||
type Compaction =
|
||||
| Started
|
||||
@@ -43,11 +43,11 @@ export function usesStandardUpdates(
|
||||
return ctx.compaction && (!child || ctx.childUpdates)
|
||||
}
|
||||
|
||||
export function apply(event: LifecycleEvent, tracked: Tracked, standard: boolean) {
|
||||
export function apply(event: LifecycleEvent, tracked: Tracked, standardUpdates: boolean) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const started = open(event)
|
||||
return { tracked: new Map(tracked).set(sessionID, started), updates: [update(started, standard)] }
|
||||
return { tracked: new Map(tracked).set(sessionID, started), updates: [update(started, standardUpdates)] }
|
||||
}
|
||||
const current = tracked.get(sessionID)
|
||||
const remaining = new Map(tracked)
|
||||
@@ -60,35 +60,41 @@ export function apply(event: LifecycleEvent, tracked: Tracked, standard: boolean
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
}
|
||||
return { tracked: remaining, updates: [update(completed, standard)] }
|
||||
return { tracked: remaining, updates: [update(completed, standardUpdates)] }
|
||||
}
|
||||
// Auto compaction can fail without a started event; open it first for standard clients.
|
||||
const started = current ?? open(event)
|
||||
const failed: Compaction = { ...started, status: "failed", reason: event.data.reason, error: event.data.error }
|
||||
return {
|
||||
tracked: remaining,
|
||||
updates: [...(current || !standard ? [] : [update(started, standard)]), update(failed, standard)],
|
||||
updates: [
|
||||
...(current || !standardUpdates ? [] : [update(started, standardUpdates)]),
|
||||
update(failed, standardUpdates),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function chunk(started: Started | undefined, text: string, standard: boolean): SessionUpdate | undefined {
|
||||
if (!started || !standard) return undefined
|
||||
export function chunk(started: Started | undefined, text: string, standardUpdates: boolean): SessionUpdate | undefined {
|
||||
if (!started || !standardUpdates) return undefined
|
||||
return { sessionUpdate: "compaction_summary_chunk", compactionId: started.messageId, content: { type: "text", text } }
|
||||
}
|
||||
|
||||
export function abandon(started: Started, standard: boolean) {
|
||||
return update({ ...started, status: "failed", error: { type: "aborted", message: "Compaction cancelled" } }, standard)
|
||||
export function abandon(started: Started, standardUpdates: boolean) {
|
||||
return update(
|
||||
{ ...started, status: "failed", error: { type: "aborted", message: "Compaction cancelled" } },
|
||||
standardUpdates,
|
||||
)
|
||||
}
|
||||
|
||||
// Nothing on this connection would settle a replayed running compaction.
|
||||
export function replay(message: Extract<SessionMessage.Info, { type: "compaction" }>, standard: boolean) {
|
||||
export function replay(message: Extract<SessionMessage.Info, { type: "compaction" }>, standardUpdates: boolean) {
|
||||
if (message.status === "running") return undefined
|
||||
const base = { messageId: message.id, reason: message.reason }
|
||||
return update(
|
||||
message.status === "failed"
|
||||
? { ...base, status: "failed", error: message.error }
|
||||
: { ...base, status: "completed", summary: message.summary },
|
||||
standard,
|
||||
standardUpdates,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,8 +107,8 @@ function open(event: OpeningEvent): Started {
|
||||
}
|
||||
}
|
||||
|
||||
function update(compaction: Compaction, standard: boolean): SessionUpdate {
|
||||
if (!standard) {
|
||||
function update(compaction: Compaction, standardUpdates: boolean): SessionUpdate {
|
||||
if (!standardUpdates) {
|
||||
const marker = {
|
||||
status: compaction.status,
|
||||
messageId: compaction.messageId,
|
||||
@@ -125,12 +131,12 @@ function update(compaction: Compaction, standard: boolean): SessionUpdate {
|
||||
compactionId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
error: errorMessage(compaction.error),
|
||||
error: displayError(compaction.error),
|
||||
}
|
||||
}
|
||||
|
||||
// Core reports a defect as `compaction.failed` with a multi-line pretty-printed cause.
|
||||
function errorMessage(error: SessionError.Error) {
|
||||
function displayError(error: SessionError.Error) {
|
||||
if (!error.message || (error.type === "compaction.failed" && error.message.includes("\n"))) return "Compaction failed"
|
||||
return error.message
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { builtinCommands, type Catalog } from "./catalog"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
export type ConfigOptionModel = {
|
||||
type ConfigOptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
variants?: ReadonlyArray<string>
|
||||
@@ -17,7 +17,7 @@ export type ConfigOptionProvider = {
|
||||
models: ReadonlyArray<ConfigOptionModel>
|
||||
}
|
||||
|
||||
export type ConfigOptionMode = {
|
||||
type ConfigOptionMode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
@@ -163,5 +163,3 @@ function selectVariant(variant: string | undefined, variants: readonly string[])
|
||||
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
|
||||
return variants[0] ?? DEFAULT_VARIANT_VALUE
|
||||
}
|
||||
|
||||
export * as ACPConfigOption from "./config-option"
|
||||
@@ -29,8 +29,6 @@ export interface Interface {
|
||||
readonly responded: (requestId: JsonRpcId) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Connection") {}
|
||||
|
||||
export function make(app: AgentApp, stream: Stream) {
|
||||
const responses = new Map<JsonRpcId, Deferred.Deferred<void>>()
|
||||
const writer = stream.writable.getWriter()
|
||||
@@ -51,7 +49,7 @@ export function make(app: AgentApp, stream: Stream) {
|
||||
})
|
||||
return {
|
||||
agent,
|
||||
connection: Service.of({
|
||||
connection: {
|
||||
sessionUpdate: (params) => promise(() => agent.client.notify(methods.client.session.update, params)),
|
||||
requestPermission: (params) =>
|
||||
promise((signal) =>
|
||||
@@ -67,7 +65,7 @@ export function make(app: AgentApp, stream: Stream) {
|
||||
responses.set(requestId, responded)
|
||||
return Deferred.await(responded)
|
||||
},
|
||||
}),
|
||||
} satisfies Interface,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import type { ContentBlock, ContentChunk, ResourceLink } from "@agentclientprotocol/sdk"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Result } from "effect"
|
||||
|
||||
export type PromptPart =
|
||||
| { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean }
|
||||
| { readonly type: "file"; readonly url: string; readonly filename?: string; readonly mime: string }
|
||||
|
||||
export type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
|
||||
type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
|
||||
|
||||
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
|
||||
return content.flatMap(contentBlockToParts)
|
||||
}
|
||||
|
||||
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
switch (block.type) {
|
||||
case "text": {
|
||||
const audience = block.annotations?.audience
|
||||
@@ -35,22 +36,19 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
return [resourceLinkToPart(block)]
|
||||
case "resource":
|
||||
if ("text" in block.resource) {
|
||||
try {
|
||||
const parsed = new URL(block.resource.uri)
|
||||
if (parsed.protocol === "file:") {
|
||||
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
|
||||
const decoded = (() => {
|
||||
try {
|
||||
return fileURLToPath(parsed)
|
||||
} catch {
|
||||
return decodeURIComponent(parsed.pathname)
|
||||
}
|
||||
})()
|
||||
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
|
||||
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
|
||||
}
|
||||
} catch {}
|
||||
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
|
||||
const parsed = URL.canParse(block.resource.uri) ? new URL(block.resource.uri) : undefined
|
||||
const decoded =
|
||||
parsed?.protocol === "file:"
|
||||
? Result.try(() => fileURLToPath(parsed)).pipe(
|
||||
Result.orElse(() => Result.try(() => decodeURIComponent(parsed.pathname))),
|
||||
Result.getOrUndefined,
|
||||
)
|
||||
: undefined
|
||||
if (!parsed || decoded === undefined)
|
||||
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
|
||||
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
|
||||
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
|
||||
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
|
||||
}
|
||||
if (!block.resource.mimeType) return []
|
||||
return [
|
||||
@@ -171,5 +169,3 @@ function filenameFromUri(uri: string | undefined): string | undefined {
|
||||
}
|
||||
return path.basename(uri) || undefined
|
||||
}
|
||||
|
||||
export * as ACPContent from "./content"
|
||||
@@ -6,10 +6,11 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import type { ACPService } from "./service"
|
||||
|
||||
export type AskedForm = Omit<Form.Info, "id"> & { readonly id: string }
|
||||
type InputField = Exclude<Form.Field, Form.ExternalField>
|
||||
@@ -27,7 +28,7 @@ type Input = {
|
||||
readonly form: Form.Info
|
||||
readonly requestedSchema: ElicitationSchema
|
||||
readonly clientSessionID: string
|
||||
readonly child?: { readonly id: string; readonly title?: string }
|
||||
readonly child?: ACPChild.Session
|
||||
readonly toolCallSent: boolean
|
||||
readonly settled: Effect.Effect<void>
|
||||
}
|
||||
@@ -58,7 +59,7 @@ function cancel(client: OpenCodeClient, form: Form.Info, message?: string) {
|
||||
return client.session.form.cancel({ sessionID: form.sessionID, formID: form.id, message }).pipe(
|
||||
Effect.catchTag(["FormAlreadySettledError", "FormNotFoundError"], () => Effect.void),
|
||||
Effect.catch(() =>
|
||||
Schema.decodeUnknownEffect(Session.ID)(form.sessionID).pipe(
|
||||
ACPClient.decodeSessionID(form.sessionID).pipe(
|
||||
Effect.flatMap((sessionID) => client.session.interrupt({ sessionID })),
|
||||
Effect.ignore,
|
||||
),
|
||||
@@ -66,7 +67,7 @@ function cancel(client: OpenCodeClient, form: Form.Info, message?: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabilities): ElicitationSchema | undefined {
|
||||
export function requestedSchema(form: AskedForm, capabilities: Capabilities): ElicitationSchema | undefined {
|
||||
if (!capabilities.formElicitation) return undefined
|
||||
if (Option.isNone(Schema.decodeUnknownOption(ElicitedKind)(form.metadata))) return undefined
|
||||
if (form.fields.some(credentialLike)) return undefined
|
||||
@@ -90,7 +91,7 @@ export function cancelUnshown(client: OpenCodeClient, form: Form.Info) {
|
||||
)
|
||||
}
|
||||
|
||||
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
|
||||
function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
|
||||
if (response.action !== "accept") return undefined
|
||||
const content = Schema.decodeUnknownOption(Form.Answer)(response.content ?? {})
|
||||
if (Option.isNone(content)) return undefined
|
||||
@@ -108,8 +109,8 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const response = yield* input.connection.createElicitation({
|
||||
mode: "form",
|
||||
sessionId: input.clientSessionID,
|
||||
...(toolCallID ? { toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID } : {}),
|
||||
message: input.child?.title ? `${input.child.title}: ${input.form.title}` : input.form.title,
|
||||
...(toolCallID ? { toolCallId: ACPChild.toolCallID(input.child, toolCallID) } : {}),
|
||||
message: ACPChild.prefixTitle(input.child, input.form.title),
|
||||
requestedSchema: input.requestedSchema,
|
||||
})
|
||||
return answer(input.form, response) ?? "cancel"
|
||||
@@ -152,7 +153,8 @@ function properties(field: InputField): Array<[string, ElicitationPropertySchema
|
||||
const base = { title: field.title, description: field.description }
|
||||
switch (field.type) {
|
||||
case "string": {
|
||||
if (!hasOptions(field)) return [[field.key, { type: "string", ...base, ...text(field), default: field.default }]]
|
||||
if (!hasOptions(field))
|
||||
return [[field.key, { type: "string", ...base, ...stringConstraints(field), default: field.default }]]
|
||||
const select: ElicitationPropertySchema = {
|
||||
type: "string",
|
||||
...base,
|
||||
@@ -192,13 +194,13 @@ function other(field: SelectField, description: string): [string, ElicitationPro
|
||||
type: "string",
|
||||
title: `${field.title ?? field.key} (other)`,
|
||||
description,
|
||||
...(field.type === "string" ? text(field) : {}),
|
||||
...(field.type === "string" ? stringConstraints(field) : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Core rejects an empty string for a required field.
|
||||
function text(field: Form.StringField) {
|
||||
function stringConstraints(field: Form.StringField) {
|
||||
return {
|
||||
format: field.format,
|
||||
minLength: field.required ? Math.max(field.minLength ?? 0, 1) : field.minLength,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
|
||||
sessionId: Schema.String,
|
||||
@@ -55,22 +56,21 @@ export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailable
|
||||
{},
|
||||
) {}
|
||||
|
||||
const Errors = Schema.Union([
|
||||
SessionNotFoundError,
|
||||
SessionDirectoryMismatchError,
|
||||
InvalidConfigOptionError,
|
||||
InvalidModelError,
|
||||
InvalidEffortError,
|
||||
InvalidModeError,
|
||||
InvalidAdditionalDirectoryError,
|
||||
AuthRequiredError,
|
||||
UnknownAuthMethodError,
|
||||
InvalidRequestError,
|
||||
ServiceFailureError,
|
||||
ServerUnavailableError,
|
||||
])
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| SessionDirectoryMismatchError
|
||||
| InvalidConfigOptionError
|
||||
| InvalidModelError
|
||||
| InvalidEffortError
|
||||
| InvalidModeError
|
||||
| InvalidAdditionalDirectoryError
|
||||
| AuthRequiredError
|
||||
| UnknownAuthMethodError
|
||||
| InvalidRequestError
|
||||
| ServiceFailureError
|
||||
| ServerUnavailableError
|
||||
|
||||
export type Error = typeof Errors.Type
|
||||
export type Failure = Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export function toRequestError(error: Error): RequestError {
|
||||
switch (error._tag) {
|
||||
@@ -118,9 +118,9 @@ export function toRequestError(error: Error): RequestError {
|
||||
return exhaustive
|
||||
}
|
||||
|
||||
export function fromUnknown(error: unknown, service?: string) {
|
||||
export function fromUnknown(error: unknown) {
|
||||
const errorName = error instanceof Error ? error.name : undefined
|
||||
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
|
||||
return new ServiceFailureError({ safeMessage: "Internal service failure", errorName })
|
||||
}
|
||||
|
||||
export * as ACPError from "./error"
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PermissionOption, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
@@ -6,9 +6,9 @@ import type { Session } from "@opencode/schema/session"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { applyPatch } from "diff"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
@@ -23,7 +23,8 @@ type Input = {
|
||||
readonly clientSessionID: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly child?: ACPTranslate.ChildSession
|
||||
readonly child?: ACPChild.Session
|
||||
readonly settled: Effect.Effect<void>
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
@@ -37,7 +38,13 @@ const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
|
||||
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(cancelled.pipe(Effect.as("reject" as const), Effect.raceFirst(ask(input)))).pipe(
|
||||
restore(
|
||||
cancelled.pipe(
|
||||
Effect.as("reject" as const),
|
||||
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask(input)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed("reject" as const)),
|
||||
Effect.flatMap((decision) => respond(input, decision)),
|
||||
@@ -51,14 +58,11 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
|
||||
Effect.orElseSucceed((): Preview[] => []),
|
||||
)
|
||||
const toolCallID = input.tool?.id ?? input.event.data.id
|
||||
const title = permissionTitle(toolName, toolInput, previews)
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID,
|
||||
toolCallId: ACPChild.toolCallID(input.child, input.tool?.id ?? input.event.data.id),
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.child?.title, permissionTitle(toolName, toolInput, previews)),
|
||||
},
|
||||
state: { input: toolInput, title: title ? ACPChild.prefixTitle(input.child, title) : input.child?.title },
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const result = yield* input.connection.requestPermission({
|
||||
@@ -68,7 +72,7 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
rawInput: input.tool ? toolCall.rawInput : undefined,
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data, input.cwd),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
...(input.child ? { _meta: ACPTranslate.childSessionMeta(input.child) } : {}),
|
||||
...(input.child ? { _meta: ACPChild.meta(input.child) } : {}),
|
||||
},
|
||||
options,
|
||||
})
|
||||
@@ -76,16 +80,12 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
return selected === "once" || selected === "always" ? selected : "reject"
|
||||
})
|
||||
|
||||
function respond(input: Input, decision: Permission.Reply) {
|
||||
return input.client.permission
|
||||
.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
}
|
||||
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
if (!prefix) return title
|
||||
if (!title) return prefix
|
||||
return `${prefix}: ${title}`
|
||||
function respond(input: Input, decision: Permission.Reply | "settled") {
|
||||
if (decision === "settled") return Effect.void
|
||||
return input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }).pipe(
|
||||
Effect.catchTag("PermissionNotFoundError", () => Effect.void),
|
||||
Effect.catch(ACPClient.classify),
|
||||
)
|
||||
}
|
||||
|
||||
// Core trims the patch tool's diffs for display, which breaks `applyPatch`, so its previews come from its own hunks.
|
||||
@@ -104,7 +104,7 @@ const permissionPreviews = Effect.fnUntraced(function* (
|
||||
Effect.gen(function* () {
|
||||
const path = absolutePath(file.file, cwd)
|
||||
const oldText = file.status === "added" ? null : yield* Effect.tryPromise(() => Bun.file(path).text())
|
||||
const newText = applyPatch(oldText ?? "", file.patch)
|
||||
const newText = yield* Effect.try(() => applyPatch(oldText ?? "", file.patch))
|
||||
return newText === false ? [] : [diff(path, oldText, newText)]
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
@@ -158,12 +158,7 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
|
||||
}
|
||||
}
|
||||
|
||||
function permissionLocations(
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
ask: PermissionEvent["data"],
|
||||
cwd: string,
|
||||
): ToolCallLocation[] {
|
||||
function permissionLocations(toolName: string, input: ToolInput, ask: PermissionEvent["data"], cwd: string) {
|
||||
const locations = toLocations(toolName, input, cwd)
|
||||
if (locations.length > 0 || !PathActions.has(ask.action)) return locations
|
||||
const paths = ask.resources.flatMap((resource) => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect, Option, Stream } from "effect"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { ACPClient } from "./client"
|
||||
import { ACPCompaction } from "./compaction"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { partsToContentChunks } from "./content"
|
||||
import type { Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate } from "./tool"
|
||||
|
||||
export function history(
|
||||
client: OpenCodeClient,
|
||||
connection: ACPConnection.Interface,
|
||||
attached: Attached,
|
||||
capabilities: Capabilities,
|
||||
) {
|
||||
return Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
(cursor
|
||||
? client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
: client.message.list({ sessionID: attached.id, limit: 200, order: "asc" })
|
||||
).pipe(
|
||||
Effect.catch(ACPClient.classify),
|
||||
Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const),
|
||||
),
|
||||
).pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.forEach(
|
||||
updates(message, attached.cwd, capabilities),
|
||||
(update) => connection.sessionUpdate({ sessionId: attached.id, update }),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function updates(message: SessionMessage.Info, cwd: string, capabilities: Capabilities): SessionUpdate[] {
|
||||
if (message.type === "user")
|
||||
return [
|
||||
{ sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } },
|
||||
...partsToContentChunks(
|
||||
(message.files ?? []).map((file) => ({
|
||||
type: "file",
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
})),
|
||||
).map((chunk) => ({ sessionUpdate: "user_message_chunk" as const, messageId: message.id, ...chunk })),
|
||||
]
|
||||
if (message.type === "compaction") {
|
||||
const update = ACPCompaction.replay(message, capabilities.compaction)
|
||||
return update ? [update] : []
|
||||
}
|
||||
if (message.type !== "assistant") return []
|
||||
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
|
||||
const reasoning = message.content.filter((part) => part.type === "reasoning")
|
||||
return message.content.flatMap((part): SessionUpdate[] => {
|
||||
if (part.type === "text")
|
||||
return [
|
||||
{ sessionUpdate: "agent_message_chunk", messageId: message.id, content: { type: "text", text: part.text } },
|
||||
]
|
||||
if (part.type === "reasoning")
|
||||
return [
|
||||
{
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: ACPTranslate.reasoningMessageID(message.id, reasoning.indexOf(part)),
|
||||
content: { type: "text", text: part.text },
|
||||
},
|
||||
]
|
||||
const call: SessionUpdate = {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.status === "streaming" ? {} : part.state.input },
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
switch (part.state.status) {
|
||||
case "completed":
|
||||
return [
|
||||
call,
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
]
|
||||
case "running":
|
||||
return [
|
||||
call,
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({ toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, cwd }),
|
||||
},
|
||||
]
|
||||
case "error":
|
||||
return [
|
||||
call,
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
]
|
||||
case "streaming":
|
||||
return [call]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export * as ACPReplay from "./replay"
|
||||
@@ -3,7 +3,7 @@ import { SessionsCursor } from "@opencode/protocol/groups/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { DateTime, Effect, Option, Ref, Schema, Stream } from "effect"
|
||||
import { DateTime, Effect, Ref, Schema } from "effect"
|
||||
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
|
||||
import type {
|
||||
AuthenticateRequest,
|
||||
@@ -22,11 +22,11 @@ import type {
|
||||
ListSessionsResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestError,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
@@ -35,41 +35,34 @@ import type {
|
||||
SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { ACPCapabilities, type Capabilities } from "./capabilities"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPDirectories } from "./directories"
|
||||
import { ACPError } from "./error"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import { ACPReplay } from "./replay"
|
||||
import type { ACPSessions, Attached, SupportedMcpServer } from "./sessions"
|
||||
import type { ACPTurn } from "./turn"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export type Capabilities = {
|
||||
readonly childSessionUpdates: boolean
|
||||
readonly formElicitation: boolean
|
||||
readonly compaction: boolean
|
||||
}
|
||||
const AuthMethodID = "opencode-login"
|
||||
|
||||
export interface Interface {
|
||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse>
|
||||
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Failure>
|
||||
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Failure>
|
||||
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Failure>
|
||||
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Failure>
|
||||
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, Failure>
|
||||
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Failure>
|
||||
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Failure>
|
||||
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Failure>
|
||||
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, ACPError.Failure>
|
||||
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, ACPError.Failure>
|
||||
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, ACPError.Failure>
|
||||
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, ACPError.Failure>
|
||||
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, ACPError.Failure>
|
||||
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, ACPError.Failure>
|
||||
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, ACPError.Failure>
|
||||
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, ACPError.Failure>
|
||||
readonly setSessionConfigOption: (
|
||||
input: SetSessionConfigOptionRequest,
|
||||
) => Effect.Effect<SetSessionConfigOptionResponse, Failure>
|
||||
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Failure>
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
|
||||
) => Effect.Effect<SetSessionConfigOptionResponse, ACPError.Failure>
|
||||
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, ACPError.Failure>
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, ACPError.Failure>
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -85,7 +78,7 @@ export function make(input: {
|
||||
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
|
||||
})
|
||||
|
||||
const withReload = <A>(attached: Attached, select: Effect.Effect<A, Failure>) => {
|
||||
const withReload = <A>(attached: Attached, select: Effect.Effect<A, ACPError.Failure>) => {
|
||||
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(select))
|
||||
return select.pipe(
|
||||
Effect.catchTags({ ACPInvalidModelError: retry, ACPInvalidModeError: retry, ACPInvalidEffortError: retry }),
|
||||
@@ -131,36 +124,9 @@ export function make(input: {
|
||||
return session
|
||||
})
|
||||
|
||||
const replay = Effect.fnUntraced(function* (attached: Attached) {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
yield* Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
(cursor
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" })
|
||||
).pipe(
|
||||
Effect.catch(ACPClient.classify),
|
||||
Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const),
|
||||
),
|
||||
).pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.forEach(
|
||||
ACPTranslate.replayMessage(message, attached.cwd, capabilities),
|
||||
(update) => input.connection.sessionUpdate({ sessionId: attached.id, update }),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
initialize: Effect.fnUntraced(function* (params) {
|
||||
const elicitation = params.clientCapabilities?.elicitation
|
||||
const compaction = params.clientCapabilities?.session?.compaction
|
||||
yield* Ref.set(input.capabilities, {
|
||||
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
|
||||
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
|
||||
compaction: compaction !== undefined && compaction !== null,
|
||||
})
|
||||
yield* Ref.set(input.capabilities, ACPCapabilities.parse(params.clientCapabilities))
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -178,7 +144,7 @@ export function make(input: {
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ACPTranslate.ChildSessionUpdatesCapability]: true },
|
||||
_meta: { [ACPCapabilities.ChildSessionUpdates]: true },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
@@ -191,23 +157,30 @@ export function make(input: {
|
||||
}),
|
||||
newSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
// Load first so a catalog failure leaves no session.
|
||||
yield* input.catalog.get(params.cwd)
|
||||
const created = yield* input.client.session
|
||||
.create({ location: { directory: AbsolutePath.make(params.cwd) }, ...ACPDirectories.grant(directories) })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
const attachment = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
|
||||
const attachment = yield* input.sessions.attach(created, params.cwd, mcpServers)
|
||||
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
|
||||
}),
|
||||
loadSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attached = (yield* input.sessions.attach(session, session.location.directory, params.mcpServers)).attached
|
||||
return yield* replay(attached).pipe(
|
||||
Effect.andThen(currentOptions(attached)),
|
||||
const attachment = yield* input.sessions.attach(session, session.location.directory, mcpServers)
|
||||
return yield* ACPReplay.history(
|
||||
input.client,
|
||||
input.connection,
|
||||
attachment.attached,
|
||||
yield* Ref.get(input.capabilities),
|
||||
).pipe(
|
||||
Effect.andThen(currentOptions(attachment.attached)),
|
||||
Effect.map((configOptions) => ({ configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
Effect.onError(() => input.sessions.release(attachment.attached)),
|
||||
)
|
||||
}),
|
||||
listSessions: Effect.fnUntraced(function* (params) {
|
||||
@@ -247,9 +220,10 @@ export function make(input: {
|
||||
}),
|
||||
resumeSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attachment = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
const attachment = yield* input.sessions.attach(session, session.location.directory, mcpServers)
|
||||
return { configOptions: attachment.configOptions }
|
||||
}),
|
||||
closeSession: Effect.fnUntraced(function* (params) {
|
||||
@@ -259,11 +233,12 @@ export function make(input: {
|
||||
}),
|
||||
forkSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
const sessionID = yield* ACPClient.decodeSessionID(params.sessionId)
|
||||
const forked = yield* input.client.session.fork({ sessionID }).pipe(Effect.catch(ACPClient.classify))
|
||||
// Forks inherit the source's grants; replace them with this request's.
|
||||
yield* ACPDirectories.activate(input.client, forked, directories)
|
||||
const attachment = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
const attachment = yield* input.sessions.attach(forked, forked.location.directory, mcpServers)
|
||||
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
|
||||
}),
|
||||
setSessionConfigOption: Effect.fnUntraced(function* (params) {
|
||||
@@ -283,6 +258,18 @@ export function make(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const supportedMcpServers = Effect.fnUntraced(function* (servers: readonly McpServer[] = []) {
|
||||
const supported = servers.filter(
|
||||
(server): server is SupportedMcpServer => !("type" in server) || server.type === "http",
|
||||
)
|
||||
if (supported.length < servers.length)
|
||||
return yield* new ACPError.InvalidRequestError({
|
||||
message: "Only stdio and HTTP MCP servers are supported",
|
||||
field: "mcpServers",
|
||||
})
|
||||
return supported
|
||||
})
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: Model.Ref) {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { McpServer, RequestError, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import { Mcp } from "@opencode/schema/mcp"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Context, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { availableCommands, configOptions, type Selection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
export type SupportedMcpServer = Exclude<McpServer, { readonly type: "acp" | "sse" }>
|
||||
|
||||
export type Attached = {
|
||||
readonly id: Session.ID
|
||||
readonly cwd: string
|
||||
@@ -20,18 +22,14 @@ export interface Interface {
|
||||
readonly attach: (
|
||||
session: Session.Info,
|
||||
cwd: string,
|
||||
mcpServers: readonly McpServer[],
|
||||
) => Effect.Effect<
|
||||
{ readonly attached: Attached; readonly configOptions: SessionConfigOption[] },
|
||||
ACPError.Error | RequestError | ACPCatalog.Error
|
||||
>
|
||||
mcpServers: readonly SupportedMcpServer[],
|
||||
) => Effect.Effect<{ readonly attached: Attached; readonly configOptions: SessionConfigOption[] }, ACPError.Failure>
|
||||
readonly detach: (sessionID: string) => Effect.Effect<void>
|
||||
readonly release: (attached: Attached) => Effect.Effect<void>
|
||||
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
|
||||
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Sessions") {}
|
||||
|
||||
type Entry = {
|
||||
readonly attached: Attached
|
||||
readonly scope: Scope.Closeable
|
||||
@@ -48,7 +46,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const scope = yield* Effect.scope
|
||||
const sessions = new Map<string, Entry>()
|
||||
// Outlives entries so re-attaching does not re-add servers.
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const registeredMcpBySession = new Map<string, Map<string, Mcp.ServerConfig>>()
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
// Subscribe before any attach so a switch right after `sessions.set` reaches the session.
|
||||
@@ -66,7 +64,9 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
event.type === "session.model.selected" ? { model: event.data.model } : { modeID: event.data.agent },
|
||||
)
|
||||
}),
|
||||
Effect.ignore,
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP selection event stream failed", cause),
|
||||
),
|
||||
Effect.ensuring(Deferred.succeed(connected, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
@@ -89,21 +89,20 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (!isDeepStrictEqual(next.commands, previous.commands)) yield* sendCommands(attached.id, next)
|
||||
})
|
||||
|
||||
const registerMcp = (attached: Attached, servers: readonly McpServer[]) =>
|
||||
const registerMcp = (attached: Attached, servers: readonly SupportedMcpServer[]) =>
|
||||
Effect.suspend(() => {
|
||||
const registered = registeredMcp.get(attached.id) ?? new Set<string>()
|
||||
registeredMcp.set(attached.id, registered)
|
||||
const registered = registeredMcpBySession.get(attached.id) ?? new Map<string, Mcp.ServerConfig>()
|
||||
registeredMcpBySession.set(attached.id, registered)
|
||||
return Effect.forEach(
|
||||
servers,
|
||||
(server) =>
|
||||
Effect.suspend(() => {
|
||||
const config = mcpConfig(server)
|
||||
const key = `${server.name}:${stableStringify(config)}`
|
||||
if (registered.has(key)) return Effect.void
|
||||
registered.add(key)
|
||||
if (isDeepStrictEqual(registered.get(server.name), config)) return Effect.void
|
||||
registered.set(server.name, config)
|
||||
return input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }).pipe(
|
||||
Effect.catch(ACPClient.classify),
|
||||
Effect.onError(() => Effect.sync(() => registered.delete(key))),
|
||||
Effect.onError(() => Effect.sync(() => registered.delete(server.name))),
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
}),
|
||||
@@ -115,12 +114,12 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
Effect.suspend(() => {
|
||||
if (sessions.get(sessionID) === entry) {
|
||||
sessions.delete(sessionID)
|
||||
registeredMcp.delete(sessionID)
|
||||
registeredMcpBySession.delete(sessionID)
|
||||
}
|
||||
return Scope.close(entry.scope, Exit.void)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
return {
|
||||
attach: Effect.fn("cli.acp.sessions.attach")(function* (session, cwd, mcpServers) {
|
||||
yield* Deferred.await(connected)
|
||||
const current = yield* input.catalog.get(cwd)
|
||||
@@ -166,7 +165,11 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const entry = sessions.get(sessionID)
|
||||
if (entry) yield* remove(sessionID, entry)
|
||||
}),
|
||||
require: Effect.fn("cli.acp.sessions.require")(function* (sessionID) {
|
||||
release: Effect.fn("cli.acp.sessions.release")(function* (attached) {
|
||||
const entry = sessions.get(attached.id)
|
||||
if (entry?.attached === attached) yield* remove(attached.id, entry)
|
||||
}),
|
||||
require: Effect.fnUntraced(function* (sessionID) {
|
||||
const entry = sessions.get(sessionID)
|
||||
if (!entry) return yield* new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
||||
return entry.attached
|
||||
@@ -176,12 +179,11 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
|
||||
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
|
||||
}),
|
||||
})
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
function mcpConfig(server: McpServer) {
|
||||
function mcpConfig(server: SupportedMcpServer) {
|
||||
if ("type" in server) {
|
||||
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
|
||||
return new Mcp.RemoteConfig({
|
||||
type: "remote",
|
||||
url: server.url,
|
||||
@@ -196,13 +198,4 @@ function mcpConfig(server: McpServer) {
|
||||
})
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
|
||||
if (!value || typeof value !== "object") return JSON.stringify(value)
|
||||
return `{${Object.entries(value)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(",")}}`
|
||||
}
|
||||
|
||||
export * as ACPSessions from "./sessions"
|
||||
@@ -1,16 +1,13 @@
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
import type { Tool } from "@opencode/schema/tool"
|
||||
import { readDisplayText } from "@opencode/tui/mini/tool"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { Result } from "effect"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
export type ToolContent = ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | null }
|
||||
>
|
||||
|
||||
export function toToolKind(toolName: string): ToolKind {
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
case "shell":
|
||||
@@ -85,18 +82,16 @@ export function pendingToolCall(input: {
|
||||
export function runningToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: { readonly input: ToolInput; readonly title?: string }
|
||||
readonly content?: ToolContent
|
||||
readonly state: { readonly input: ToolInput }
|
||||
readonly cwd: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
title: toolTitle(input.toolName, input.state.input),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
...(input.content?.length ? { content: toolContent(input.content) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +99,7 @@ export function completedToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly content: ReadonlyArray<Tool.Content>
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly cwd: string
|
||||
}): ToolCallUpdate {
|
||||
@@ -138,7 +133,7 @@ export function errorToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content?: ToolContent
|
||||
readonly content?: ReadonlyArray<Tool.Content>
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly error: string
|
||||
readonly cwd: string
|
||||
@@ -147,7 +142,7 @@ export function errorToolUpdate(input: {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "failed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: toolTitle(input.toolName, input.input, undefined),
|
||||
title: toolTitle(input.toolName, input.input),
|
||||
locations: toLocations(input.toolName, input.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.input, input.cwd),
|
||||
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
|
||||
@@ -158,7 +153,7 @@ export function errorToolUpdate(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function toolContent(content: ToolContent): ToolCallContent[] {
|
||||
function toolContent(content: ReadonlyArray<Tool.Content>): ToolCallContent[] {
|
||||
return content.flatMap((part): ToolCallContent[] => {
|
||||
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
|
||||
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
|
||||
@@ -167,7 +162,7 @@ function toolContent(content: ToolContent): ToolCallContent[] {
|
||||
})
|
||||
}
|
||||
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback?: string) {
|
||||
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
|
||||
return fallback || toolName
|
||||
}
|
||||
@@ -208,5 +203,3 @@ export function absolutePath(path: string, cwd: string) {
|
||||
export function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as ACPTool from "./tool"
|
||||
@@ -4,14 +4,11 @@ import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPCompaction } from "./compaction"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import type { ACPService } from "./service"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
|
||||
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: SessionMessage.ID }
|
||||
@@ -42,37 +39,14 @@ type RetryStatus = {
|
||||
readonly error: SessionError.Error
|
||||
}
|
||||
|
||||
export type ChildSession = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
type ChildSessionEvent =
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
export type ChildSessionUpdate = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
} & ChildSessionEvent
|
||||
|
||||
export type TurnState = {
|
||||
readonly started: boolean
|
||||
readonly tools: ReadonlyMap<string, Tool>
|
||||
readonly retries: ReadonlyMap<string, RetryStatus>
|
||||
readonly compactions: ACPCompaction.Tracked
|
||||
readonly children: ReadonlyMap<string, ChildSession>
|
||||
readonly children: ReadonlyMap<string, ACPChild.Session>
|
||||
readonly openChildren: ReadonlySet<string>
|
||||
readonly forms: ReadonlySet<string>
|
||||
readonly asks: ReadonlySet<string>
|
||||
readonly finish?: SessionMessage.Assistant["finish"]
|
||||
readonly usage?: { readonly turn: TokenUsage.Info; readonly last: TokenUsage.Info }
|
||||
readonly stepError?: SessionError.Error
|
||||
@@ -84,22 +58,22 @@ type FormEvent = Extract<OpenCodeEvent, { type: "form.created" }>
|
||||
|
||||
export type Output =
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
|
||||
| { readonly _tag: "ChildUpdate"; readonly update: ChildSessionUpdate }
|
||||
| { readonly _tag: "ChildUpdate"; readonly update: ACPChild.Update }
|
||||
| {
|
||||
readonly _tag: "PermissionAsk"
|
||||
readonly event: PermissionEvent
|
||||
readonly tool?: Tool
|
||||
readonly child?: ChildSession
|
||||
readonly child?: ACPChild.Session
|
||||
}
|
||||
| {
|
||||
readonly _tag: "FormAsk"
|
||||
readonly form: FormEvent["data"]["form"]
|
||||
readonly child?: ChildSession
|
||||
readonly child?: ACPChild.Session
|
||||
readonly toolCallSent: boolean
|
||||
}
|
||||
| { readonly _tag: "FormSettled"; readonly formID: string }
|
||||
| { readonly _tag: "AskSettled"; readonly id: string }
|
||||
|
||||
export type Step = {
|
||||
type Step = {
|
||||
readonly state: TurnState
|
||||
readonly outputs: ReadonlyArray<Output>
|
||||
readonly terminal?: Terminal
|
||||
@@ -112,7 +86,7 @@ export const initial: TurnState = {
|
||||
compactions: new Map(),
|
||||
children: new Map(),
|
||||
openChildren: new Set(),
|
||||
forms: new Set(),
|
||||
asks: new Set(),
|
||||
}
|
||||
|
||||
export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step {
|
||||
@@ -141,11 +115,14 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
const tool = event.data.source?.id
|
||||
? state.tools.get(toolKey(event.data.sessionID, event.data.source.id))
|
||||
: undefined
|
||||
return { state, outputs: [{ _tag: "PermissionAsk", event, tool, child }] }
|
||||
return {
|
||||
state: { ...state, asks: new Set(state.asks).add(event.data.id) },
|
||||
outputs: [{ _tag: "PermissionAsk", event, tool, child }],
|
||||
}
|
||||
}
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
|
||||
return {
|
||||
state: { ...state, forms: new Set(state.forms).add(event.data.form.id) },
|
||||
state: { ...state, asks: new Set(state.asks).add(event.data.form.id) },
|
||||
outputs: [
|
||||
{
|
||||
_tag: "FormAsk",
|
||||
@@ -156,13 +133,20 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
],
|
||||
}
|
||||
}
|
||||
if ((event.type === "form.replied" || event.type === "form.cancelled") && state.forms.has(event.data.id)) {
|
||||
const forms = new Set(state.forms)
|
||||
forms.delete(event.data.id)
|
||||
return { state: { ...state, forms }, outputs: [{ _tag: "FormSettled", formID: event.data.id }] }
|
||||
const settledID =
|
||||
event.type === "permission.replied"
|
||||
? event.data.requestID
|
||||
: event.type === "form.replied" || event.type === "form.cancelled"
|
||||
? event.data.id
|
||||
: undefined
|
||||
if (settledID && state.asks.has(settledID)) {
|
||||
const asks = new Set(state.asks)
|
||||
asks.delete(settledID)
|
||||
return { state: { ...state, asks }, outputs: [{ _tag: "AskSettled", id: settledID }] }
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
|
||||
if (matchesStart(event, ctx.start)) return { state: { ...state, started: true }, outputs: [] }
|
||||
if (event.type === "session.inbox.delivered" && event.data.inboxID === ctx.start.id)
|
||||
return { state: { ...state, started: true }, outputs: [] }
|
||||
if (!state.started) return { state, outputs: [] }
|
||||
|
||||
switch (event.type) {
|
||||
@@ -219,7 +203,7 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
state,
|
||||
outputs: send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
|
||||
messageId: reasoningMessageID(event.data.assistantMessageID, event.data.ordinal),
|
||||
content: { type: "text", text: event.data.delta },
|
||||
}),
|
||||
}
|
||||
@@ -396,126 +380,31 @@ export function abandon(state: TurnState, ctx: Context): Step {
|
||||
}
|
||||
}
|
||||
|
||||
export function* replayMessage(
|
||||
message: SessionMessage.Info,
|
||||
cwd: string,
|
||||
capabilities: ACPService.Capabilities,
|
||||
): Generator<SessionUpdate> {
|
||||
if (message.type === "user") {
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } }
|
||||
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
|
||||
type: "file",
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
}))
|
||||
for (const chunk of partsToContentChunks(files))
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk }
|
||||
return
|
||||
}
|
||||
if (message.type === "compaction") {
|
||||
const update = ACPCompaction.replay(message, capabilities.compaction)
|
||||
if (update) yield update
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
|
||||
let reasoningOrdinal = 0
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
yield { sessionUpdate: "agent_message_chunk", messageId: message.id, content: { type: "text", text: part.text } }
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
yield {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
|
||||
content: { type: "text", text: part.text },
|
||||
}
|
||||
continue
|
||||
}
|
||||
yield {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.status === "streaming" ? {} : part.state.input },
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
switch (part.state.status) {
|
||||
case "completed":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
break
|
||||
case "running":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({ toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, cwd }),
|
||||
}
|
||||
break
|
||||
case "error":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
break
|
||||
case "streaming":
|
||||
break
|
||||
}
|
||||
}
|
||||
export function reasoningMessageID(messageID: string, ordinal: number) {
|
||||
return `${messageID}:reasoning:${ordinal}`
|
||||
}
|
||||
|
||||
function newTool(sessionID: string, id: string, name = "tool"): Tool {
|
||||
return { sessionID, id, name, input: {}, metadata: {} }
|
||||
}
|
||||
|
||||
function route(ctx: Context, child: ChildSession | undefined, update: SessionUpdate): Output[] {
|
||||
function route(ctx: Context, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
|
||||
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
|
||||
const projected = projectChildUpdate(update, child)
|
||||
const projected = ACPChild.project(update, child)
|
||||
if (ctx.childUpdates) return childStatus(ctx, child, { type: "update", update: projected })
|
||||
return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update: projected }] : []
|
||||
}
|
||||
|
||||
function childStatus(ctx: Context, child: ChildSession, value: ChildSessionEvent): Output[] {
|
||||
function childStatus(ctx: Context, child: ACPChild.Session, event: ACPChild.Event): Output[] {
|
||||
if (!ctx.childUpdates) return []
|
||||
return [
|
||||
{
|
||||
_tag: "ChildUpdate",
|
||||
update: {
|
||||
rootSessionId: ctx.sessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...value,
|
||||
},
|
||||
},
|
||||
]
|
||||
return [{ _tag: "ChildUpdate", update: ACPChild.update(ctx.sessionID, child, event) }]
|
||||
}
|
||||
|
||||
function childEnded(
|
||||
state: TurnState,
|
||||
ctx: Context,
|
||||
child: ChildSession,
|
||||
status: ChildSessionEvent,
|
||||
child: ACPChild.Session,
|
||||
status: ACPChild.Event,
|
||||
terminal: Terminal,
|
||||
): Step {
|
||||
const openChildren = new Set(state.openChildren)
|
||||
@@ -561,31 +450,6 @@ function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = { ...projected._meta, ...childSessionMeta(child) }
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
export function childSessionMeta(child: ChildSession) {
|
||||
return {
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function matchesStart(event: OpenCodeEvent, start: TurnStart) {
|
||||
return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
readonly terminal: Terminal
|
||||
readonly finish: SessionMessage.Assistant["finish"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import {
|
||||
@@ -21,7 +20,9 @@ import {
|
||||
} from "effect"
|
||||
import { access, constants } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import { currentModel } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
@@ -29,21 +30,16 @@ import { linkReference, promptContentToParts, type PromptPart } from "./content"
|
||||
import { ACPElicitation } from "./elicitation"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPermission } from "./permission"
|
||||
import type { ACPService } from "./service"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
|
||||
type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export interface Interface {
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, ACPError.Failure>
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
|
||||
/** Unlike `cancel`, interrupts an idle session too, since server work can outlive its turn. */
|
||||
readonly close: (sessionID: string) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Turn") {}
|
||||
|
||||
/** Core acknowledges an interrupt before its cleanup settles, and its shell tool waits 3s before SIGKILL. */
|
||||
export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/cli/acp/Turn/CancelDrainTimeout", {
|
||||
defaultValue: () => "5 seconds",
|
||||
@@ -66,7 +62,7 @@ type Subscription = {
|
||||
/** Asks run serially off the event stream. */
|
||||
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
|
||||
readonly cancelled: Deferred.Deferred<void>
|
||||
readonly forms: Map<string, Deferred.Deferred<void>>
|
||||
readonly settled: Map<string, Deferred.Deferred<void>>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
@@ -74,11 +70,11 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly sessions: ACPSessions.Interface
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
readonly capabilities: Ref.Ref<ACPService.Capabilities>
|
||||
readonly capabilities: Ref.Ref<Capabilities>
|
||||
}) {
|
||||
const scope = yield* Effect.scope
|
||||
const drainTimeout = yield* CancelDrainTimeout
|
||||
const turns = yield* FiberMap.make<string, PromptResponse, Failure>()
|
||||
const turns = yield* FiberMap.make<string, PromptResponse, ACPError.Failure>()
|
||||
|
||||
const subscribe = Effect.fnUntraced(function* () {
|
||||
// Parented to the service scope; the session scope may already be closed.
|
||||
@@ -90,7 +86,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
.pipe(Stream.toQueue({ capacity: "unbounded" }), Scope.provide(subscriptionScope)),
|
||||
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
forms: new Map(),
|
||||
settled: new Map(),
|
||||
}
|
||||
yield* Queue.take(subscription.asks).pipe(
|
||||
Effect.flatten,
|
||||
@@ -116,7 +112,12 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
yield* Deferred.await(settled)
|
||||
})
|
||||
|
||||
const reply = (subscription: Subscription, ctx: ACPTranslate.Context, ask: PermissionAsk) =>
|
||||
const reply = (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
ask: PermissionAsk,
|
||||
settled: Deferred.Deferred<void>,
|
||||
) =>
|
||||
ACPPermission.reply(
|
||||
{
|
||||
client: input.client,
|
||||
@@ -127,6 +128,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
cwd: ctx.cwd,
|
||||
tool: ask.tool,
|
||||
child: ask.child,
|
||||
settled: Deferred.await(settled),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
)
|
||||
@@ -137,17 +139,27 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
|
||||
case "ChildUpdate":
|
||||
return input.connection
|
||||
.extNotification(ACPTranslate.ChildSessionUpdateMethod, output.update)
|
||||
.pipe(Effect.ignoreCause)
|
||||
.extNotification(ACPChild.UpdateMethod, output.update)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logWarning("ACP child session update failed", cause),
|
||||
),
|
||||
)
|
||||
case "PermissionAsk":
|
||||
return Queue.offer(subscription.asks, reply(subscription, ctx, output)).pipe(Effect.asVoid)
|
||||
return Effect.gen(function* () {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.settled.set(output.event.data.id, settled)
|
||||
yield* Queue.offer(subscription.asks, reply(subscription, ctx, output, settled))
|
||||
})
|
||||
case "FormAsk":
|
||||
return Effect.gen(function* () {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.forms.set(output.form.id, settled)
|
||||
subscription.settled.set(output.form.id, settled)
|
||||
yield* Queue.offer(
|
||||
subscription.asks,
|
||||
ACPElicitation.reply(
|
||||
@@ -165,10 +177,10 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
),
|
||||
)
|
||||
})
|
||||
case "FormSettled":
|
||||
case "AskSettled":
|
||||
return Effect.suspend(() => {
|
||||
const settled = subscription.forms.get(output.formID)
|
||||
subscription.forms.delete(output.formID)
|
||||
const settled = subscription.settled.get(output.id)
|
||||
subscription.settled.delete(output.id)
|
||||
return settled ? Deferred.succeed(settled, undefined) : Effect.void
|
||||
})
|
||||
}
|
||||
@@ -181,8 +193,10 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
) {
|
||||
while (true) {
|
||||
const event = yield* take(subscription)
|
||||
const next = ACPTranslate.step(yield* Ref.get(state), event, ctx)
|
||||
yield* Ref.set(state, next.state)
|
||||
const next = yield* Ref.modify(state, (current) => {
|
||||
const step = ACPTranslate.step(current, event, ctx)
|
||||
return [step, step.state]
|
||||
})
|
||||
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
|
||||
if (next.terminal) {
|
||||
yield* asksSettled(subscription)
|
||||
@@ -226,19 +240,22 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
})
|
||||
|
||||
const interruptServer = (sessionID: Session.ID) =>
|
||||
input.client.session.interrupt({ sessionID }).pipe(Effect.ignoreCause)
|
||||
|
||||
const windDown = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
events: Fiber.Fiber<ACPTranslate.Terminal, Failure>,
|
||||
events: Fiber.Fiber<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
yield* Deferred.succeed(subscription.cancelled, undefined)
|
||||
yield* interruptServer(ctx.sessionID)
|
||||
yield* input.client.session
|
||||
.interrupt({ sessionID: ctx.sessionID })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP server interrupt failed", cause),
|
||||
),
|
||||
)
|
||||
if (!(yield* Ref.get(state)).started) return
|
||||
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
|
||||
if (Option.exists(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)), Exit.isSuccess)) return
|
||||
yield* Fiber.interrupt(events)
|
||||
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
|
||||
yield* Ref.set(state, abandoned.state)
|
||||
@@ -276,7 +293,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
const close = Scope.close(subscription.scope, Exit.void)
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
|
||||
@@ -294,7 +311,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const settle = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
const current = yield* Ref.get(state)
|
||||
@@ -323,7 +340,9 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
},
|
||||
})
|
||||
},
|
||||
(effect) => Effect.ignoreCause(effect),
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP usage update failed", cause),
|
||||
),
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
@@ -342,8 +361,8 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
return yield* settle(attached, state, exit)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
|
||||
return {
|
||||
prompt: Effect.fnUntraced(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
|
||||
@@ -371,7 +390,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
yield* aborted(signal).pipe(Effect.andThen(Fiber.interrupt(turn)), Effect.forkChild)
|
||||
return yield* Fiber.join(turn)
|
||||
}),
|
||||
cancel: Effect.fn("cli.acp.turn.cancel")(function* (params) {
|
||||
cancel: Effect.fnUntraced(function* (params) {
|
||||
yield* FiberMap.remove(turns, params.sessionId)
|
||||
}),
|
||||
close: Effect.fn("cli.acp.turn.close")(function* (sessionID) {
|
||||
@@ -382,7 +401,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
Effect.catch(ACPClient.classify),
|
||||
)
|
||||
}),
|
||||
})
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
function aborted(signal: AbortSignal) {
|
||||
@@ -401,8 +420,17 @@ function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID
|
||||
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
|
||||
const slash = detectSlashCommand(text)
|
||||
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
|
||||
const start = turnStart(messageID, slash)
|
||||
return { start, text, files, synthetic, slash, command }
|
||||
return {
|
||||
start:
|
||||
slash && builtinCommands.get(slash.name)?.start === "compaction"
|
||||
? { type: "compaction", id: messageID }
|
||||
: { type: "input", id: messageID },
|
||||
text,
|
||||
files,
|
||||
synthetic,
|
||||
slash,
|
||||
command,
|
||||
}
|
||||
}
|
||||
|
||||
function referenceUnreadableFile(part: PromptPart) {
|
||||
@@ -413,12 +441,7 @@ function referenceUnreadableFile(part: PromptPart) {
|
||||
)
|
||||
}
|
||||
|
||||
function turnStart(messageID: SessionMessage.ID, slash: PreparedPrompt["slash"]): ACPTranslate.TurnStart {
|
||||
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
|
||||
function detectSlashCommand(text: string) {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { OpenCode, type PluginInfo } from "@opencode/client"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Config } from "../../../config"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { readFile, rename, writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Service, type Endpoint } from "@opencode/client/effect/service"
|
||||
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode/client/promise"
|
||||
import { OpenCode, type SessionMessageAssistantTool } from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Deferred, Effect, Schema, Stream } from "effect"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { makeSession, rpcError, startWire, type Wire } from "./wire-fixture"
|
||||
|
||||
const key = "opencode.acp.additionalDirectories"
|
||||
@@ -28,56 +26,6 @@ const updates = (acp: Wire) =>
|
||||
acp.server.requests.filter((request) => request.method === "PATCH" && request.path.startsWith("/api/session/"))
|
||||
|
||||
describe("acp additional directories over the wire", () => {
|
||||
test("session/new grants normalized unique directories other than cwd and lists them", async () => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", {
|
||||
cwd: "/workspace",
|
||||
additionalDirectories: ["/shared/lib/", "/workspace", "/docs/../product-docs", "/shared/lib", "/workspace/"],
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
|
||||
permissions: [grant(sharedLib), grant(productDocs)],
|
||||
metadata: { [key]: [sharedLib, productDocs] },
|
||||
})
|
||||
expect((await acp.request("session/list", { cwd: "/workspace" })).sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: created.sessionId,
|
||||
additionalDirectories: [sharedLib, productDocs],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("grants both the written and real spelling of a symlinked root and drops links to cwd", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = await fs.realpath(tmp.path)
|
||||
const cwd = path.join(root, "workspace")
|
||||
const shared = path.join(root, "shared")
|
||||
await Promise.all([fs.mkdir(cwd), fs.mkdir(shared)])
|
||||
await Promise.all([
|
||||
fs.symlink(shared, path.join(root, "shared-link")),
|
||||
fs.symlink(cwd, path.join(root, "workspace-link")),
|
||||
])
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", {
|
||||
cwd,
|
||||
additionalDirectories: [path.join(root, "workspace-link"), path.join(root, "shared-link")],
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
|
||||
permissions: [grant(path.join(root, "shared-link")), grant(shared)],
|
||||
metadata: { [key]: [path.join(root, "shared-link")] },
|
||||
})
|
||||
expect((await acp.request("session/list", { cwd })).sessions[0]?.additionalDirectories).toEqual([
|
||||
path.join(root, "shared-link"),
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["shared/lib", "", "/shared/*", "/shared/lib?"])(
|
||||
"rejects %p before creating a session",
|
||||
async (directory) => {
|
||||
@@ -155,28 +103,4 @@ describe("acp additional directories over the wire", () => {
|
||||
})
|
||||
expect(acp.server.sessions.get("ses_source")?.permissions).toEqual([grant(old), ...other])
|
||||
})
|
||||
|
||||
test("leaves sessions alone without additional directories", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_saved", {
|
||||
...makeSession("ses_saved"),
|
||||
metadata: { host: "tui" },
|
||||
permissions: [userGrant, ...other],
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", { cwd: "/workspace", mcpServers: [] })
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers: [] })
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_saved", additionalDirectories: [] })
|
||||
|
||||
const create = acp.server.requests.find((request) => request.method === "POST" && request.path === "/api/session")
|
||||
expect(create?.body).toMatchObject({ location: { directory: "/workspace" }, permissions: null, metadata: null })
|
||||
expect(acp.server.sessions.get(created.sessionId)?.permissions).toBeUndefined()
|
||||
expect(updates(acp)).toEqual([])
|
||||
expect(
|
||||
(await acp.request("session/list", { cwd: "/workspace" })).sessions.map(
|
||||
(session) => session.additionalDirectories,
|
||||
),
|
||||
).toEqual([undefined, undefined])
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { CloseSessionResponse, PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
// The mock model holds any completion whose request mentions "hold" until the test releases it,
|
||||
// so cancellation can be exercised while the model turn is genuinely in flight.
|
||||
function heldModel() {
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<string>()
|
||||
return {
|
||||
started: started.promise,
|
||||
release: () => release.resolve("released"),
|
||||
respond(request: unknown) {
|
||||
if (!JSON.stringify(request).includes("hold")) return "accepted"
|
||||
started.resolve()
|
||||
return release.promise
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("acp cancel subprocess", () => {
|
||||
test("$/cancel_request cancels the in-flight prompt and the session stays usable", async () => {
|
||||
const model = heldModel()
|
||||
await using fixture = await createAcpFixture({ respond: model.respond })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const prompt = acp.send<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hold" }],
|
||||
})
|
||||
await model.started
|
||||
await acp.notify("$/cancel_request", { requestId: prompt.id })
|
||||
|
||||
expect(expectOk(await prompt.response).stopReason).toBe("cancelled")
|
||||
model.release()
|
||||
const next = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "again" }],
|
||||
}),
|
||||
)
|
||||
expect(next.stopReason).toBe("end_turn")
|
||||
}, 60_000)
|
||||
|
||||
test("session/close settles the active prompt before responding and leaves other sessions alone", async () => {
|
||||
const model = heldModel()
|
||||
await using fixture = await createAcpFixture({ respond: model.respond })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const closing = await newSession(acp, fixture.home)
|
||||
const other = await newSession(acp, fixture.home)
|
||||
|
||||
const prompt = acp.send<PromptResponse>("session/prompt", {
|
||||
sessionId: closing.sessionId,
|
||||
prompt: [{ type: "text", text: "hold" }],
|
||||
})
|
||||
await model.started
|
||||
const order: string[] = []
|
||||
const close = acp
|
||||
.request<CloseSessionResponse>("session/close", { sessionId: closing.sessionId })
|
||||
.then((response) => {
|
||||
order.push("close")
|
||||
return response
|
||||
})
|
||||
const cancelled = await prompt.response.then((response) => {
|
||||
order.push("prompt")
|
||||
return response
|
||||
})
|
||||
|
||||
expect(expectOk(cancelled).stopReason).toBe("cancelled")
|
||||
expect(expectOk(await close)).toEqual({})
|
||||
expect(order).toEqual(["prompt", "close"])
|
||||
model.release()
|
||||
const next = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: other.sessionId,
|
||||
prompt: [{ type: "text", text: "still here" }],
|
||||
}),
|
||||
)
|
||||
expect(next.stopReason).toBe("end_turn")
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Clock, Duration, Effect, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { it } from "../../../core/test/lib/effect"
|
||||
import { ACPCatalog } from "../../src/acp/catalog"
|
||||
import { buildAgent, makeClient, planAgent, startWire, testModel, type Wire, type WireOptions } from "./wire-fixture"
|
||||
|
||||
describe("acp catalog service", () => {
|
||||
it.effect("coalesces reloads requested during a reload into one more load", () => {
|
||||
const gate = { held: false, release: Promise.withResolvers<void>() }
|
||||
return withCatalog(
|
||||
{
|
||||
fetch: (request) =>
|
||||
request.path === "/api/agent" && gate.held ? gate.release.promise.then(() => undefined) : undefined,
|
||||
},
|
||||
(acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
yield* catalog.get("/workspace")
|
||||
gate.held = true
|
||||
|
||||
const running = yield* catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.promise(() => acp.until(() => requests(acp, "/api/agent") === 2, "the held reload"))
|
||||
const queued = yield* Effect.all(
|
||||
[0, 1].map(() => catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))),
|
||||
)
|
||||
acp.server.catalog.agents = [planAgent, buildAgent]
|
||||
gate.held = false
|
||||
gate.release.resolve()
|
||||
yield* Fiber.join(running)
|
||||
yield* Fiber.joinAll(queued)
|
||||
|
||||
expect(reads(acp, "agent")).toBe(3)
|
||||
expect((yield* catalog.get("/workspace")).defaultModeID).toBe(Agent.ID.make("plan"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps the previous catalog when a reload fails", () => {
|
||||
const failing = { model: false }
|
||||
return withCatalog(
|
||||
{
|
||||
fetch: (request) =>
|
||||
failing.model && request.path === "/api/model"
|
||||
? Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
|
||||
: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
const before = yield* catalog.get("/workspace")
|
||||
failing.model = true
|
||||
|
||||
yield* catalog.reload("/workspace")
|
||||
|
||||
expect(yield* catalog.get("/workspace")).toBe(before)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("waits 25ms between readiness reads", () =>
|
||||
withCatalog({}, (acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
acp.server.catalog.models = []
|
||||
|
||||
const loading = yield* catalog.get("/workspace").pipe(Effect.forkChild)
|
||||
yield* Effect.promise(() => acp.until(() => reads(acp, "model") === 1, "the first model read"))
|
||||
acp.server.catalog.models = [testModel]
|
||||
yield* advance("5 millis", () => reads(acp, "model") === 2)
|
||||
const retriedAt = yield* Clock.currentTimeMillis
|
||||
const loaded = yield* Fiber.join(loading)
|
||||
|
||||
expect(retriedAt).toBeGreaterThanOrEqual(25)
|
||||
expect(loaded.defaultModel).toMatchObject({ providerID: "test", id: "test-model", variant: "default" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("gives up with the last readiness failure after 5 seconds", () =>
|
||||
withCatalog({}, (acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
acp.server.catalog.agents = []
|
||||
|
||||
const loading = yield* catalog.get("/workspace").pipe(Effect.flip, Effect.timed, Effect.forkChild)
|
||||
yield* advance("25 millis", () => loading.pollUnsafe() !== undefined)
|
||||
const [elapsed, error] = yield* Fiber.join(loading)
|
||||
|
||||
expect(error).toEqual(new ACPCatalog.NotReadyError({ reason: "agents" }))
|
||||
expect(error.message).toBe("No primary agents are available")
|
||||
// Reads in flight while the clock steps push the last attempt slightly past the deadline.
|
||||
expect(Duration.toMillis(elapsed)).toBeGreaterThanOrEqual(5_000)
|
||||
expect(Duration.toMillis(elapsed)).toBeLessThan(6_000)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function withCatalog<A, E>(options: WireOptions, body: (acp: Wire) => Effect.Effect<A, E, ACPCatalog.Service>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => startWire(options)),
|
||||
(acp) => Effect.promise(() => acp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((acp) =>
|
||||
body(acp).pipe(
|
||||
Effect.provideServiceEffect(
|
||||
ACPCatalog.Service,
|
||||
makeClient(acp.server.url).pipe(Effect.flatMap(ACPCatalog.make)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Catalog reads are real HTTP that settles between sleeps, so the clock moves in steps until the reads catch up.
|
||||
function advance(step: Duration.Input, done: () => boolean) {
|
||||
return TestClock.adjust(step).pipe(
|
||||
Effect.andThen(TestClock.withLive(Effect.sleep("1 millis"))),
|
||||
Effect.repeat({ until: done }),
|
||||
)
|
||||
}
|
||||
|
||||
function reads(acp: Wire, kind: "model" | "agent") {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === kind).length
|
||||
}
|
||||
|
||||
function requests(acp: Wire, path: string) {
|
||||
return acp.server.requests.filter((request) => request.path === path).length
|
||||
}
|
||||
@@ -1,230 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionConfigOption, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { currentValue, requireSelectOption, selectValues } from "./select-options"
|
||||
import {
|
||||
buildAgent,
|
||||
ephemeralEvent,
|
||||
planAgent,
|
||||
reviewCommand,
|
||||
rpcError,
|
||||
secondModel,
|
||||
startSession,
|
||||
startWire,
|
||||
testModel,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
import { currentValue } from "./select-options"
|
||||
import { rpcError, secondModel, startSession } from "./wire-fixture"
|
||||
|
||||
describe("acp catalog and config options over the wire", () => {
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const first = await Promise.all([acp.newSession("/workspace"), acp.newSession("/workspace")])
|
||||
const other = await acp.newSession("/other")
|
||||
|
||||
expect(currentValue(first[0], "model")).toBe("test/test-model")
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
(["model", "default", "agent", "command"] as const).map((kind) =>
|
||||
acp.server.catalogReads.filter((read) => read.kind === kind).map((read) => read.directory),
|
||||
),
|
||||
).toEqual(Array.from({ length: 4 }, () => ["/workspace", "/other"]))
|
||||
expect(
|
||||
Object.fromEntries([...acp.server.sessions.values()].map((session) => [session.id, session.location.directory])),
|
||||
).toEqual({
|
||||
[first[0].sessionId]: "/workspace",
|
||||
[first[1].sessionId]: "/workspace",
|
||||
[other.sessionId]: "/other",
|
||||
})
|
||||
await acp.until(() => acp.updates.filter((item) => commandNames(item)).length === 3, "commands for each session")
|
||||
expect(acp.updates.map(commandNames)).toEqual(Array.from({ length: 3 }, () => ["review", "compact"]))
|
||||
})
|
||||
|
||||
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
|
||||
const configured = { ...buildAgent, id: "copilot-build", name: "copilot-build" }
|
||||
await using acp = await startSession()
|
||||
expect(currentValue(acp.session, "mode")).toBe("build")
|
||||
expect(currentValue(acp.session, "model")).toBe("test/test-model")
|
||||
|
||||
const reads = agentReads(acp)
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/other" }))
|
||||
acp.server.catalog.agents = [configured, buildAgent, planAgent]
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
|
||||
|
||||
const update = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
|
||||
expect(update.update.sessionUpdate === "config_option_update" && modeOption(update.update.configOptions)).toEqual({
|
||||
currentValue: "copilot-build",
|
||||
options: ["copilot-build", "build", "plan"],
|
||||
})
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 3)
|
||||
expect(commandNames(commands)).toEqual(["review", "ship", "compact"])
|
||||
expect(agentReads(acp)).toBe(reads + 1)
|
||||
|
||||
const second = await acp.newSession()
|
||||
expect(currentValue(second, "mode")).toBe("copilot-build")
|
||||
})
|
||||
|
||||
test("defaults the mode to the first selectable agent the server lists", async () => {
|
||||
const configured = { ...buildAgent, id: "review", name: "Review", mode: "all" as const }
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.agents = [configured, buildAgent, planAgent]
|
||||
await acp.initialize()
|
||||
|
||||
const session = await acp.newSession()
|
||||
|
||||
expect(modeOption(session.configOptions ?? [])).toEqual({
|
||||
currentValue: "review",
|
||||
options: ["review", "build", "plan"],
|
||||
})
|
||||
})
|
||||
|
||||
test("pushes config options on model.updated and commands on command.updated", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession()
|
||||
expect(selectValues(session.configOptions, "model")).toEqual(["test/test-model"])
|
||||
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
const options = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
|
||||
expect(
|
||||
options.update.sessionUpdate === "config_option_update" && selectValues(options.update.configOptions, "model"),
|
||||
).toEqual(["test/second-model", "test/test-model"])
|
||||
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 3)
|
||||
expect(commands).toEqual({
|
||||
sessionId: session.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "config_option_update")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reloads the catalog before rejecting a model or mode it has not seen", async () => {
|
||||
const configured = { ...planAgent, id: "copilot-build", name: "copilot-build" }
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession()
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.catalog.agents = [buildAgent, planAgent, configured]
|
||||
const set = (configId: string, value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId, value })
|
||||
const initialModelReads = modelReads(acp)
|
||||
|
||||
const model = await set("model", "test/second-model")
|
||||
const reloadedModelReads = modelReads(acp)
|
||||
const missingModel = await rpcError(set("model", "test/missing-model"))
|
||||
const missingModelReads = modelReads(acp)
|
||||
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
|
||||
const reads = agentReads(acp)
|
||||
const missing = await rpcError(set("mode", "missing"))
|
||||
|
||||
expect(currentValue(model, "model")).toBe("test/second-model")
|
||||
expect([reloadedModelReads, missingModelReads]).toEqual([initialModelReads + 1, initialModelReads + 2])
|
||||
expect(missingModel).toMatchObject({ code: -32602, data: { modelId: "test/missing-model" } })
|
||||
expect(acp.server.selections).toContainEqual({ sessionID: session.sessionId, agent: "copilot-build" })
|
||||
expect(missing).toMatchObject({ code: -32602, data: { mode: "missing" } })
|
||||
expect(agentReads(acp)).toBeGreaterThan(reads)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[
|
||||
"a sibling session closes",
|
||||
async (acp: Wire) => {
|
||||
const closed = await acp.newSession()
|
||||
const open = await acp.newSession()
|
||||
await acp.request("session/close", { sessionId: closed.sessionId })
|
||||
await initialCommands(acp, open.sessionId, 1)
|
||||
return open.sessionId
|
||||
},
|
||||
],
|
||||
...(["session/load", "session/resume"] as const).map(
|
||||
(method) =>
|
||||
[
|
||||
`${method} re-attaches the session`,
|
||||
async (acp: Wire) => {
|
||||
const session = await acp.newSession()
|
||||
const params = { cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] }
|
||||
await acp.request(method, params)
|
||||
await acp.request(method, params)
|
||||
await initialCommands(acp, session.sessionId, 3)
|
||||
return session.sessionId
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
])("pushes exactly one update per catalog change after %s", async (_, setup) => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const sessionId = await setup(acp)
|
||||
const since = acp.updates.length
|
||||
|
||||
await change(acp, sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
})
|
||||
await change(acp, sessionId, "available_commands_update", () => {
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
|
||||
expect(updateKinds(acp, since)).toEqual([
|
||||
[sessionId, "config_option_update"],
|
||||
[sessionId, "available_commands_update"],
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["empty", "missing the default"])(
|
||||
"retries when the model list is %s but the default is ready",
|
||||
async (initial) => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = initial === "empty" ? [] : [secondModel]
|
||||
acp.server.catalog.defaultModel = testModel
|
||||
await acp.initialize()
|
||||
|
||||
const created = acp.newSession()
|
||||
await acp.until(() => modelReads(acp) === 1, "first model read")
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
const session = await created
|
||||
|
||||
const choices = selectValues(session.configOptions, "model")
|
||||
expect(choices).toContain("test/second-model")
|
||||
expect(choices).toContain("test/test-model")
|
||||
expect(currentValue(session, "model")).toBe("test/test-model")
|
||||
expect(modelReads(acp)).toBe(2)
|
||||
},
|
||||
)
|
||||
|
||||
test("does not cache a failed catalog load", async () => {
|
||||
const failure = { pending: true }
|
||||
await using acp = await startWire({
|
||||
fetch(request) {
|
||||
if (request.path !== "/api/model" || !failure.pending) return undefined
|
||||
failure.pending = false
|
||||
return Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
|
||||
},
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603 })
|
||||
expect(acp.server.sessions.size).toBe(0)
|
||||
const retried = await acp.newSession()
|
||||
|
||||
expect(acp.server.sessions.has(retried.sessionId)).toBe(true)
|
||||
expect(modelReads(acp)).toBe(1)
|
||||
})
|
||||
|
||||
test("switches model, effort, and mode against the warm catalog", async () => {
|
||||
await using acp = await startSession()
|
||||
const sessionId = acp.sessionId
|
||||
@@ -246,7 +24,6 @@ describe("acp catalog and config options over the wire", () => {
|
||||
{ sessionID: sessionId, agent: "plan" },
|
||||
{ sessionID: sessionId, agent: "build" },
|
||||
])
|
||||
expect(modelReads(acp)).toBe(1)
|
||||
|
||||
expect(await rpcError(set("effort", "maximum"))).toMatchObject({ code: -32602, data: { effort: "maximum" } })
|
||||
expect(await rpcError(set("mode", "missing"))).toMatchObject({ code: -32602, data: { mode: "missing" } })
|
||||
@@ -256,79 +33,4 @@ describe("acp catalog and config options over the wire", () => {
|
||||
data: { modelId: "test/missing-model" },
|
||||
})
|
||||
})
|
||||
|
||||
test("advertises and runs the built-in compact over a server command (https://github.com/anomalyco/opencode/issues/37229)", async () => {
|
||||
await using acp = await startSession()
|
||||
const advertised = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
|
||||
acp.server.catalog.commands = [
|
||||
reviewCommand,
|
||||
{ name: "compact", description: "Server compact" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
const replaced = await acp.waitForUpdate((item) => item !== advertised && commandNames(item) !== undefined)
|
||||
const compacted = await acp.prompt(acp.sessionId, "/compact")
|
||||
|
||||
expect([advertised, replaced].map((item) => item.update)).toEqual([
|
||||
{
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(compacted.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions.map((item) => item.kind)).toEqual(["compact"])
|
||||
})
|
||||
})
|
||||
|
||||
// Each change waits on a catalog reload over HTTP, so a stray update for an earlier change lands before the next one.
|
||||
async function change(acp: Wire, sessionId: string, kind: string, trigger: () => void) {
|
||||
const seen = acp.updates.filter((item) => item.sessionId === sessionId && item.update.sessionUpdate === kind).length
|
||||
trigger()
|
||||
await acp.until(
|
||||
() =>
|
||||
acp.updates.filter((item) => item.sessionId === sessionId && item.update.sessionUpdate === kind).length > seen,
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
// Each attach sends its commands after the response, so later counts start once every attach has sent them.
|
||||
function initialCommands(acp: Wire, sessionId: string, attaches: number) {
|
||||
return acp.until(
|
||||
() => acp.updates.filter((item) => item.sessionId === sessionId && commandNames(item)).length === attaches,
|
||||
"initial commands",
|
||||
)
|
||||
}
|
||||
|
||||
function updateKinds(acp: Wire, since: number) {
|
||||
return acp.updates.slice(since).map((item) => [item.sessionId, item.update.sessionUpdate])
|
||||
}
|
||||
|
||||
function commandNames(item: SessionNotification) {
|
||||
if (item.update.sessionUpdate !== "available_commands_update") return undefined
|
||||
return item.update.availableCommands.map((command) => command.name)
|
||||
}
|
||||
|
||||
function agentReads(acp: Wire) {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === "agent").length
|
||||
}
|
||||
|
||||
function modelReads(acp: Wire) {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === "model").length
|
||||
}
|
||||
|
||||
function modeOption(options: SessionConfigOption[]) {
|
||||
const mode = requireSelectOption(options, "mode")
|
||||
return { currentValue: mode.currentValue, options: selectValues(options, "mode") }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
|
||||
describe("acp command", () => {
|
||||
test("is registered", async () => {
|
||||
const result = await cli(["--help"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toMatch(/^ acp[ \t]+Start an Agent Client Protocol server\r?$/m)
|
||||
})
|
||||
})
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, "../.."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
@@ -1,201 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { CompactionUpdate, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect, Exit, Queue, Schema, Scope, Stream } from "effect"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
enqueued,
|
||||
ephemeralEvent,
|
||||
makeClient,
|
||||
interrupted,
|
||||
makeSession,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
turn,
|
||||
type InitializeOptions,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
import { Schema } from "effect"
|
||||
import { durableEvent, enqueued, ephemeralEvent, startSession, turn, type InitializeOptions } from "./wire-fixture"
|
||||
|
||||
const summary = "Summary of the earlier conversation"
|
||||
const providerError = { type: "provider.error", message: "summary request failed", status: 500 }
|
||||
const decodeCompact = Schema.decodeUnknownSync(Schema.Struct({ id: Schema.String }))
|
||||
const standard: InitializeOptions = { compaction: {} }
|
||||
|
||||
describe("acp compaction markers over the wire", () => {
|
||||
test("marks a /compact turn without forwarding the summary text", async () => {
|
||||
const compacted = await compactTurn((sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
])
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
|
||||
marker(acp.sessionId, { status: "completed", messageId: compacted.id, reason: "manual" }),
|
||||
])
|
||||
expect(compacted.response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("marks a failed /compact turn with the full compaction error", async () => {
|
||||
const compacted = await compactTurn((sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "manual", inputID: id, error: providerError }),
|
||||
])
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
|
||||
marker(acp.sessionId, { status: "failed", messageId: compacted.id, reason: "manual", error: providerError }),
|
||||
])
|
||||
expect(compacted.response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("marks an automatic compaction between steps with the ID its replayed message gets", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
textDelta(sessionID, "msg_before", "before"),
|
||||
stepEnded(sessionID, "msg_before"),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
|
||||
textDelta(sessionID, "msg_after", "after"),
|
||||
stepEnded(sessionID, "msg_after"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const messageId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
chunk(acp.sessionId, "msg_before", "before"),
|
||||
marker(acp.sessionId, { status: "started", messageId, reason: "auto" }),
|
||||
marker(acp.sessionId, { status: "completed", messageId, reason: "auto" }),
|
||||
chunk(acp.sessionId, "msg_after", "after"),
|
||||
])
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
|
||||
const live = turnUpdates(acp.updates)
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
{
|
||||
id: messageId,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary,
|
||||
recent: "",
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] })
|
||||
|
||||
expect(turnUpdates(acp.updates).slice(live.length)).toEqual([
|
||||
marker(acp.sessionId, { status: "completed", messageId, reason: "auto" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("marks automatic compaction failures before and after the compaction starts", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
}),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const unstarted = await events.messageID("session.compaction.failed")
|
||||
const started = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
marker(acp.sessionId, {
|
||||
status: "failed",
|
||||
messageId: unstarted,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
}),
|
||||
marker(acp.sessionId, { status: "started", messageId: started, reason: "auto" }),
|
||||
marker(acp.sessionId, { status: "failed", messageId: started, reason: "auto", error: providerError }),
|
||||
])
|
||||
})
|
||||
|
||||
test("projects child session compaction markers onto the parent turn", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
|
||||
durableEvent("session.compaction.ended", {
|
||||
sessionID: "ses_child",
|
||||
reason: "auto",
|
||||
text: summary,
|
||||
recent: "",
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const messageId = await events.messageID("session.compaction.started")
|
||||
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
|
||||
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
|
||||
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
])
|
||||
})
|
||||
|
||||
test("replays settled compactions at their position on session/load", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
|
||||
acp.server.messages.set("ses_compacted", compactedHistory())
|
||||
await acp.initialize()
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
{
|
||||
sessionId: "ses_compacted",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg_user",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
marker("ses_compacted", {
|
||||
status: "failed",
|
||||
messageId: "msg_compaction_failed",
|
||||
reason: "auto",
|
||||
error: providerError,
|
||||
}),
|
||||
marker("ses_compacted", { status: "completed", messageId: "msg_compaction", reason: "manual" }),
|
||||
chunk("ses_compacted", "msg_after", "after"),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp standard compaction updates over the wire", () => {
|
||||
test("streams a /compact turn's summary and completes it with the full summary", async () => {
|
||||
@@ -206,7 +16,7 @@ describe("acp standard compaction updates over the wire", () => {
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "the earlier conversation" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
],
|
||||
standard,
|
||||
{ compaction: {} },
|
||||
)
|
||||
await using acp = compacted.acp
|
||||
|
||||
@@ -223,343 +33,6 @@ describe("acp standard compaction updates over the wire", () => {
|
||||
expect(compacted.response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("completes a compaction without a readable summary with a cleared summary", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: "", recent: "" }),
|
||||
textDelta(sessionID, "msg_after", "after"),
|
||||
stepEnded(sessionID, "msg_after"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "completed", summary: null }),
|
||||
chunk(acp.sessionId, "msg_after", "after"),
|
||||
])
|
||||
})
|
||||
|
||||
test("fails a compaction with its error and clears the partial summary", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
summaryChunk(acp.sessionId, compactionId, "partial"),
|
||||
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: providerError.message }),
|
||||
])
|
||||
})
|
||||
|
||||
test("opens a compaction that fails before it starts before settling it", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
}),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.failed")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: "Nothing to compact yet" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("reports a defect as a generic failure and keeps core's own failure messages", async () => {
|
||||
const limit = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
|
||||
const defect = { type: "compaction.failed", message: "Error: database is locked\n at run (compaction.ts:1:1)" }
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: limit }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: defect }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const first = await events.messageID("session.compaction.started")
|
||||
const second = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId: first, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId: first, status: "failed", summary: null, error: limit.message }),
|
||||
compaction(acp.sessionId, { compactionId: second, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId: second, status: "failed", summary: null, error: "Compaction failed" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("drops summary chunks outside an open compaction", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "early" }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "late" }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "completed", summary: [{ type: "text", text: summary }] }),
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels a compaction the server interrupts during a cancelled turn's wind-down", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
interrupted(sessionID),
|
||||
],
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
|
||||
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels a compaction left open when the wind-down never reports it", async () => {
|
||||
const submitted: string[] = []
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
submitted.push(id)
|
||||
return [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_summary_chunk")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const compactionId = submitted[0]
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
|
||||
summaryChunk(acp.sessionId, compactionId, "partial").update,
|
||||
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("closes the marker of a compaction left open when the wind-down never reports it", async () => {
|
||||
const submitted: string[] = []
|
||||
await using acp = await startSession({
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
submitted.push(id)
|
||||
return [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const messageId = submitted[0]
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId, reason: "manual" }).update,
|
||||
marker(acp.sessionId, {
|
||||
status: "failed",
|
||||
messageId,
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Compaction cancelled" },
|
||||
}).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps child compactions projected onto the parent as markers", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
|
||||
durableEvent("session.compaction.ended", {
|
||||
sessionID: "ses_child",
|
||||
reason: "auto",
|
||||
text: summary,
|
||||
recent: "",
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const messageId = await events.messageID("session.compaction.started")
|
||||
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
|
||||
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
|
||||
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
])
|
||||
})
|
||||
|
||||
test("sends child compactions as standard updates through child session updates", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { ...standard, childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", {
|
||||
sessionID: "ses_child",
|
||||
reason: "auto",
|
||||
text: summary,
|
||||
recent: "",
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
const child = { "opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" } }
|
||||
expect(acp.childUpdates.flatMap((item) => (item.type === "update" ? [item.update] : []))).toEqual([
|
||||
{ sessionUpdate: "compaction_update", compactionId, status: "in_progress", _meta: child },
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
_meta: child,
|
||||
},
|
||||
])
|
||||
expect(turnUpdates(acp.updates)).toEqual([])
|
||||
})
|
||||
|
||||
test("replays settled compactions as terminal updates at their position on session/load", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
|
||||
acp.server.messages.set("ses_compacted", [
|
||||
...compactedHistory().slice(0, 3),
|
||||
{
|
||||
id: "msg_compaction_cancelled",
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Compaction cancelled" },
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_compaction_native",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: 3 },
|
||||
},
|
||||
...compactedHistory().slice(3),
|
||||
])
|
||||
await acp.initialize(standard)
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
{
|
||||
sessionId: "ses_compacted",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg_user",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
compaction("ses_compacted", {
|
||||
compactionId: "msg_compaction_failed",
|
||||
status: "failed",
|
||||
summary: null,
|
||||
error: providerError.message,
|
||||
}),
|
||||
compaction("ses_compacted", {
|
||||
compactionId: "msg_compaction",
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
}),
|
||||
compaction("ses_compacted", { compactionId: "msg_compaction_cancelled", status: "cancelled", summary: null }),
|
||||
compaction("ses_compacted", { compactionId: "msg_compaction_native", status: "completed", summary: null }),
|
||||
chunk("ses_compacted", "msg_after", "after"),
|
||||
])
|
||||
})
|
||||
|
||||
test("treats a null compaction capability as unsupported", async () => {
|
||||
const compacted = await compactTurn(
|
||||
(sessionID, id) => [
|
||||
@@ -581,7 +54,7 @@ describe("acp standard compaction updates over the wire", () => {
|
||||
// Holds the compact response so the test can publish the turn's events while the request is in flight.
|
||||
async function compactTurn(
|
||||
events: (sessionID: string, id: string) => OpenCodeEventEncoded[],
|
||||
capabilities?: InitializeOptions,
|
||||
capabilities: InitializeOptions,
|
||||
) {
|
||||
const held = Promise.withResolvers<Response>()
|
||||
const acp = await startSession({
|
||||
@@ -599,27 +72,6 @@ async function compactTurn(
|
||||
return { acp, id, response: await response }
|
||||
}
|
||||
|
||||
// Core derives an automatic compaction's message ID from the event ID the server stamps on publish.
|
||||
async function watchEvents(url: string) {
|
||||
const scope = Scope.makeUnsafe()
|
||||
const events = await Effect.runPromise(
|
||||
makeClient(url).pipe(
|
||||
Effect.flatMap((client) => client.event.subscribe().pipe(Stream.toQueue({ capacity: "unbounded" }))),
|
||||
Scope.provide(scope),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(Queue.take(events))
|
||||
return {
|
||||
async messageID(type: OpenCodeEventEncoded["type"]) {
|
||||
while (true) {
|
||||
const event = await Effect.runPromise(Queue.take(events))
|
||||
if (event.type === type) return SessionMessage.ID.fromEvent(event.id)
|
||||
}
|
||||
},
|
||||
[Symbol.dispose]: () => Effect.runFork(Scope.close(scope, Exit.void)),
|
||||
}
|
||||
}
|
||||
|
||||
function marker(sessionId: string, value: Record<string, unknown>): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "session_info_update", _meta: { "opencode/compaction": value } } }
|
||||
}
|
||||
@@ -635,59 +87,8 @@ function summaryChunk(sessionId: string, compactionId: string, text: string): Se
|
||||
}
|
||||
}
|
||||
|
||||
function chunk(sessionId: string, messageId: string, text: string): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text } } }
|
||||
}
|
||||
|
||||
const isCancelledResponse = Schema.is(
|
||||
Schema.Struct({ result: Schema.Struct({ stopReason: Schema.Literal("cancelled") }) }),
|
||||
)
|
||||
|
||||
// Session updates the client received before the cancelled prompt response.
|
||||
function updatesBeforeResponse(acp: Wire) {
|
||||
const response = acp.received.findIndex(isCancelledResponse)
|
||||
expect(response).toBeGreaterThan(-1)
|
||||
const count = acp.received
|
||||
.slice(0, response)
|
||||
.filter((message) => "method" in message && message.method === "session/update").length
|
||||
return turnUpdates(acp.updates.slice(0, count)).map((item) => item.update)
|
||||
}
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
return updates.filter(
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
)
|
||||
}
|
||||
|
||||
function compactedHistory(): Array<typeof SessionMessage.Info.Encoded> {
|
||||
return [
|
||||
{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_compaction_failed",
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
error: providerError,
|
||||
time: { created: 2 },
|
||||
},
|
||||
{
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary,
|
||||
recent: "",
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_compaction_running",
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: "auto",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: 4 },
|
||||
},
|
||||
assistantMessage("msg_after", { time: { created: 5, completed: 6 }, content: [{ type: "text", text: "after" }] }),
|
||||
]
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
buildConfigOptions,
|
||||
buildEffortSelectOption,
|
||||
buildModeSelectOption,
|
||||
buildModelSelectOption,
|
||||
formatVariantName,
|
||||
parseModelSelection,
|
||||
type ConfigOptionProvider,
|
||||
} from "../../src/acp/config-option"
|
||||
import { buildEffortSelectOption, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
|
||||
|
||||
const providers: ConfigOptionProvider[] = [
|
||||
{
|
||||
@@ -22,26 +14,6 @@ const providers: ConfigOptionProvider[] = [
|
||||
]
|
||||
|
||||
describe("acp config options", () => {
|
||||
test("builds the model select option with ACP verifier category", () => {
|
||||
expect(
|
||||
buildModelSelectOption({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
}),
|
||||
).toEqual({
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "anthropic/claude/sonnet-4",
|
||||
options: [
|
||||
{ value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
|
||||
{ value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
|
||||
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
|
||||
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
|
||||
id: "effort",
|
||||
@@ -56,96 +28,21 @@ describe("acp config options", () => {
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("effort fallback uses the first variant when default is absent", () => {
|
||||
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
|
||||
"minimal",
|
||||
)
|
||||
})
|
||||
|
||||
test("builds the mode select option with descriptions when present", () => {
|
||||
expect(
|
||||
buildModeSelectOption({
|
||||
currentModeId: "build",
|
||||
modes: [
|
||||
{ id: "build", name: "Build", description: "Make code changes" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: "build",
|
||||
options: [
|
||||
{ value: "build", name: "Build", description: "Make code changes" },
|
||||
{ value: "plan", name: "Plan" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("builds full config options with model, effort, and mode in stable order", () => {
|
||||
const options = buildConfigOptions({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
currentVariant: "very-high",
|
||||
modes: [
|
||||
{ id: "build", name: "Build" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
})
|
||||
|
||||
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
|
||||
expect(options[0]?.currentValue).toBe("anthropic/claude/sonnet-4")
|
||||
expect(options[1]?.currentValue).toBe("very-high")
|
||||
})
|
||||
|
||||
test("full config options omit effort for models without variants", () => {
|
||||
expect(
|
||||
buildConfigOptions({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
|
||||
}).map((option) => option.id),
|
||||
).toEqual(["model"])
|
||||
})
|
||||
|
||||
test("parses provider/model selections", () => {
|
||||
expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
})
|
||||
})
|
||||
|
||||
test("parses provider/model/variant selections when the base model exposes that variant", () => {
|
||||
expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "low",
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
})
|
||||
})
|
||||
|
||||
test("parses trailing variants for slash-containing model ids", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("formats variant names for display", () => {
|
||||
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
|
||||
test.each([
|
||||
["openai/gpt-5", { model: { providerID: "openai", modelID: "gpt-5" } }],
|
||||
["openai/gpt-5/low", { model: { providerID: "openai", modelID: "gpt-5" }, variant: "low" }],
|
||||
["anthropic/claude/sonnet-4", { model: { providerID: "anthropic", modelID: "claude/sonnet-4" } }],
|
||||
[
|
||||
"anthropic/claude/sonnet-4/high",
|
||||
{ model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, variant: "high" },
|
||||
],
|
||||
["anthropic/claude/sonnet-4/missing", { model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" } }],
|
||||
])("parses the model selection %s, preferring exact slash-containing model ids", (value, expected) => {
|
||||
expect(parseModelSelection(value, providers)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { alternateValue, flattenSelectOptions, requireSelectOption, selectConfigOption } from "./select-options"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp config option subprocess", () => {
|
||||
test('model option is listed with category "model"', async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const model = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "model")
|
||||
|
||||
expect(model.category).toBe("model")
|
||||
expect(model.currentValue).toBe("test/test-model")
|
||||
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
|
||||
}, 60_000)
|
||||
|
||||
test("model switch updates currentValue", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const model = requireSelectOption(session.configOptions, "model")
|
||||
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
|
||||
expect(nextModel).toBe("test/second-model")
|
||||
|
||||
const updated = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: nextModel,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
|
||||
}, 60_000)
|
||||
|
||||
test('effort option is listed with category "thought_level" when selected model supports variants', async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
|
||||
|
||||
expect(effort.category).toBe("thought_level")
|
||||
expect(effort.currentValue).toBe("default")
|
||||
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
|
||||
}, 60_000)
|
||||
|
||||
test("effort survives model synchronization and can be reset to default", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const nextEffort = alternateValue(requireSelectOption(session.configOptions, "effort"))
|
||||
|
||||
const updated = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: nextEffort,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
|
||||
|
||||
const synchronized = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: requireSelectOption(session.configOptions, "model").currentValue,
|
||||
}),
|
||||
)
|
||||
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
|
||||
|
||||
const reset = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "default",
|
||||
}),
|
||||
)
|
||||
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,271 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
|
||||
|
||||
describe("acp content conversion", () => {
|
||||
test("plain text block becomes a text part", () => {
|
||||
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
|
||||
})
|
||||
|
||||
test("assistant-only text audience becomes synthetic", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "text",
|
||||
text: "internal",
|
||||
annotations: { audience: ["assistant"] },
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "internal", synthetic: true }])
|
||||
})
|
||||
|
||||
test("user-only text audience becomes ignored", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "text",
|
||||
text: "visible to user",
|
||||
annotations: { audience: ["user"] },
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "visible to user", ignored: true }])
|
||||
})
|
||||
|
||||
test("image block with base64 data becomes a data URL file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "image",
|
||||
data: "AAAA",
|
||||
mimeType: "image/png",
|
||||
uri: "file:///tmp/screenshot.png",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:image/png;base64,AAAA",
|
||||
filename: "screenshot.png",
|
||||
mime: "image/png",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("image block with only a data URL URI becomes a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "data:image/png;base64,AAAA" }),
|
||||
).toEqual([{ type: "file", url: "data:image/png;base64,AAAA", filename: "image", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("image block with only an http URI becomes a markdown link", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "image",
|
||||
data: "",
|
||||
mimeType: "image/jpeg",
|
||||
uri: "https://example.com/assets/photo.jpg",
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "[photo.jpg](https://example.com/assets/photo.jpg)" }])
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/" }),
|
||||
).toEqual([{ type: "text", text: "[image](https://example.com/)" }])
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "http://example.com/a.png" }),
|
||||
).toEqual([{ type: "text", text: "[a.png](http://example.com/a.png)" }])
|
||||
})
|
||||
|
||||
test("image block with only a URI of another scheme or a bare path becomes a markdown link", () => {
|
||||
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "ftp://host/a.png" })).toEqual([
|
||||
{ type: "text", text: "[a.png](ftp://host/a.png)" },
|
||||
])
|
||||
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "assets/b.png" })).toEqual([
|
||||
{ type: "text", text: "[b.png](assets/b.png)" },
|
||||
])
|
||||
})
|
||||
|
||||
test("image block with only a file URI becomes a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "file:///tmp/screenshot.png" }),
|
||||
).toEqual([{ type: "file", url: "file:///tmp/screenshot.png", filename: "screenshot.png", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("image block with only a zed path becomes a file part named after the path", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "zed://file?path=/tmp/shot.png" }),
|
||||
).toEqual([{ type: "file", url: pathToFileURL("/tmp/shot.png").href, filename: "shot.png", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("resource_link file URL becomes a file part with name and fallback mime", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource_link",
|
||||
uri: "file:///tmp/notes.txt",
|
||||
name: "client-notes.txt",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "file:///tmp/notes.txt",
|
||||
filename: "client-notes.txt",
|
||||
mime: "text/plain",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link to another scheme becomes a markdown link", () => {
|
||||
expect(contentBlockToParts({ type: "resource_link", uri: "https://example.com/spec", name: "spec" })).toEqual([
|
||||
{ type: "text", text: "[spec](https://example.com/spec)" },
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link zed path becomes a file URL part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource_link",
|
||||
uri: "zed://workspace?path=/tmp/project/src/app.ts",
|
||||
name: "app.ts",
|
||||
mimeType: "text/typescript",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: pathToFileURL("/tmp/project/src/app.ts").href,
|
||||
filename: "app.ts",
|
||||
mime: "text/typescript",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("resource with text becomes a sourced text part", () => {
|
||||
const result = contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/context.txt#L12-L14",
|
||||
mimeType: "text/plain",
|
||||
text: "context",
|
||||
},
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.type).toBe("text")
|
||||
if (result[0]?.type === "text") {
|
||||
expect(result[0].text.endsWith("\ncontext")).toBe(true)
|
||||
expect(result[0].text.includes("context.txt")).toBe(true)
|
||||
expect(result[0].text.includes("12")).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("resource with text uses URI fallback for non-file resources", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "mcp://server/context",
|
||||
text: "context",
|
||||
},
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
|
||||
})
|
||||
|
||||
test("resource with text includes file path", () => {
|
||||
const result = contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/context.txt",
|
||||
mimeType: "text/plain",
|
||||
text: "context",
|
||||
},
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.type).toBe("text")
|
||||
if (result[0]?.type === "text") {
|
||||
expect(result[0].text.endsWith("\ncontext")).toBe(true)
|
||||
expect(result[0].text.includes("context.txt")).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("resource with blob and mimeType becomes a data URL file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
blob: "JVBERg==",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:application/pdf;base64,JVBERg==",
|
||||
filename: "report.pdf",
|
||||
mime: "application/pdf",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("data URL resource is preserved as a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "data:text/plain;base64,aGVsbG8=",
|
||||
mimeType: "text/plain",
|
||||
blob: "ignored",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:text/plain;base64,aGVsbG8=",
|
||||
filename: "file",
|
||||
mime: "text/plain",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("unsupported blocks are ignored", () => {
|
||||
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
|
||||
expect(
|
||||
promptContentToParts([
|
||||
// @ts-expect-error Exercise forward compatibility with an unknown ACP content block.
|
||||
{ type: "unknown", text: "skip" },
|
||||
]),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp replay conversion", () => {
|
||||
test("replays text audience annotations", () => {
|
||||
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
|
||||
{
|
||||
content: {
|
||||
type: "text",
|
||||
text: "cached",
|
||||
annotations: { audience: ["assistant"] },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("replays file and data URL parts as ACP content", () => {
|
||||
expect(
|
||||
partsToContentChunks([
|
||||
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
|
||||
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: "file:///tmp/readme.md",
|
||||
name: "readme.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
},
|
||||
{
|
||||
content: {
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: pathToFileURL("note.txt").href,
|
||||
mimeType: "text/plain",
|
||||
text: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyMessage, AnyRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk"
|
||||
import type { CreateElicitationResponse } from "@agentclientprotocol/sdk"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
interrupted,
|
||||
startSession,
|
||||
succeeded,
|
||||
textDelta,
|
||||
@@ -14,13 +11,13 @@ import {
|
||||
toolSucceeded,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const questions = (sessionID: string, id = "frm_question", tool = "call_question") =>
|
||||
const questions = (sessionID: string, id = "frm_question") =>
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id,
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "msg_tools", id: tool } },
|
||||
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
@@ -57,130 +54,7 @@ const accept = (content: Record<string, string | number | boolean | string[]>):
|
||||
content,
|
||||
})
|
||||
|
||||
const pendingUntilAborted = (_request: unknown, signal: AbortSignal) =>
|
||||
new Promise<CreateElicitationResponse>((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ action: "cancel" }), { once: true })
|
||||
})
|
||||
|
||||
const firstElicitationCancel = (received: readonly AnyMessage[]): AnyMessage => {
|
||||
const asked = received.find(
|
||||
(message): message is AnyRequest =>
|
||||
"method" in message && "id" in message && message.method === "elicitation/create",
|
||||
)
|
||||
return { jsonrpc: "2.0", method: "$/cancel_request", params: { requestId: asked?.id } }
|
||||
}
|
||||
|
||||
describe("acp elicitation mapping", () => {
|
||||
test("maps every representable field type", () => {
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{
|
||||
key: "email",
|
||||
title: "Email",
|
||||
description: "Work address",
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 80,
|
||||
pattern: ".+@.+",
|
||||
placeholder: "you@example.com",
|
||||
default: "a@b.co",
|
||||
required: true,
|
||||
},
|
||||
{ key: "name", type: "string", minLength: 2 },
|
||||
{
|
||||
key: "plan",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "pro", label: "Pro", description: "Paid" },
|
||||
{ value: "free", label: "Free" },
|
||||
],
|
||||
default: "free",
|
||||
},
|
||||
{ key: "ratio", type: "number", minimum: 0, maximum: 1, default: 0.5 },
|
||||
{ key: "count", type: "integer", minimum: 1, required: true },
|
||||
{ key: "confirm", type: "boolean", default: false },
|
||||
{
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
maxItems: 2,
|
||||
default: ["a"],
|
||||
required: true,
|
||||
},
|
||||
{ key: "server", type: "string", format: "uri", hidden: true, default: "https://example.com" },
|
||||
]),
|
||||
capable,
|
||||
),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
title: "Email",
|
||||
description: "Work address",
|
||||
format: "email",
|
||||
minLength: 1,
|
||||
maxLength: 80,
|
||||
pattern: ".+@.+",
|
||||
default: "a@b.co",
|
||||
},
|
||||
name: { type: "string", minLength: 2 },
|
||||
plan: {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{ const: "pro", title: "Pro", description: "Paid" },
|
||||
{ const: "free", title: "Free" },
|
||||
],
|
||||
default: "free",
|
||||
},
|
||||
ratio: { type: "number", minimum: 0, maximum: 1, default: 0.5 },
|
||||
count: { type: "integer", minimum: 1 },
|
||||
confirm: { type: "boolean", default: false },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: "a", title: "A" },
|
||||
{ const: "b", title: "B" },
|
||||
],
|
||||
},
|
||||
minItems: 1,
|
||||
maxItems: 2,
|
||||
default: ["a"],
|
||||
},
|
||||
},
|
||||
required: ["email", "count", "tags"],
|
||||
})
|
||||
})
|
||||
|
||||
test("adds a free-text property next to options that accept a custom answer", () => {
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{
|
||||
key: "q0",
|
||||
title: "Runtime",
|
||||
type: "string",
|
||||
options: [{ value: "Bun", label: "Bun" }],
|
||||
custom: true,
|
||||
maxLength: 20,
|
||||
},
|
||||
{ key: "q1", type: "multiselect", options: [{ value: "Fast", label: "Fast" }], custom: true },
|
||||
]),
|
||||
capable,
|
||||
)?.properties,
|
||||
).toEqual({
|
||||
q0: { type: "string", title: "Runtime", oneOf: [{ const: "Bun", title: "Bun" }] },
|
||||
q0_custom: { type: "string", title: "Runtime (other)", description: "Type your own answer", maxLength: 20 },
|
||||
q1: { type: "array", items: { anyOf: [{ const: "Fast", title: "Fast" }] } },
|
||||
q1_custom: { type: "string", title: "q1 (other)", description: "Add your own answer" },
|
||||
})
|
||||
})
|
||||
|
||||
test("cancels forms from unsupported clients, unknown flows, and credential-looking fields", () => {
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
|
||||
expect(ACPElicitation.requestedSchema(form(fields), { ...capable, formElicitation: false })).toBeUndefined()
|
||||
@@ -198,91 +72,6 @@ describe("acp elicitation mapping", () => {
|
||||
credentials.map(() => undefined),
|
||||
)
|
||||
})
|
||||
|
||||
test("cancels forms it cannot represent faithfully", () => {
|
||||
const options = [{ value: "a", label: "A" }]
|
||||
const unrepresentable: Array<ACPElicitation.AskedForm["fields"]> = [
|
||||
[
|
||||
{ key: "mode", type: "boolean" },
|
||||
{ key: "detail", type: "string", when: [{ key: "mode", op: "eq", value: true }] },
|
||||
],
|
||||
[
|
||||
{ key: "mode", type: "boolean" },
|
||||
{ key: "detail", type: "string", hidden: true, default: "x", when: [{ key: "mode", op: "eq", value: true }] },
|
||||
],
|
||||
[{ key: "login", type: "external", url: "https://example.com/login" }],
|
||||
[{ key: "server", type: "string", hidden: true, required: true }],
|
||||
[{ key: "pick", type: "string", options, custom: true, required: true }],
|
||||
[{ key: "pick", type: "multiselect", options, custom: true, maxItems: 1 }],
|
||||
[{ key: "pick", type: "string", options, default: "b" }],
|
||||
[{ key: "pick", type: "multiselect", options, default: ["a", "b"] }],
|
||||
[
|
||||
{ key: "pick", type: "string", options, custom: true },
|
||||
{ key: "pick_custom", type: "string" },
|
||||
],
|
||||
]
|
||||
expect(unrepresentable.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
|
||||
unrepresentable.map(() => undefined),
|
||||
)
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{ key: "name", type: "string" },
|
||||
{ key: "pick", type: "string", options, default: "b", hidden: true },
|
||||
]),
|
||||
capable,
|
||||
)?.properties,
|
||||
).toEqual({ name: { type: "string" } })
|
||||
})
|
||||
|
||||
test("maps an accepted response back to answers", () => {
|
||||
const options = [{ value: "a", label: "A" }]
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [
|
||||
{ key: "single", type: "string", options, custom: true },
|
||||
{ key: "multi", type: "multiselect", options, custom: true },
|
||||
{ key: "blank", type: "string", options, custom: true },
|
||||
{ key: "count", type: "integer" },
|
||||
{ key: "server", type: "string", hidden: true, default: "https://example.com" },
|
||||
{ key: "region", type: "string", hidden: true },
|
||||
]
|
||||
expect(
|
||||
ACPElicitation.answer(
|
||||
form(fields),
|
||||
accept({
|
||||
single: "a",
|
||||
single_custom: "typed",
|
||||
multi: ["a"],
|
||||
multi_custom: "extra",
|
||||
blank: "a",
|
||||
blank_custom: " ",
|
||||
count: 3,
|
||||
server: "https://other.example.com",
|
||||
unknown: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
single: "typed",
|
||||
multi: ["a", "extra"],
|
||||
blank: "a",
|
||||
count: 3,
|
||||
server: "https://example.com",
|
||||
})
|
||||
expect(ACPElicitation.answer(form(fields), accept({ multi_custom: "only" }))).toEqual({
|
||||
multi: ["only"],
|
||||
server: "https://example.com",
|
||||
})
|
||||
expect(ACPElicitation.answer(form(fields), { action: "accept" })).toEqual({ server: "https://example.com" })
|
||||
})
|
||||
|
||||
test("has no answer unless the user accepted valid content", () => {
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
|
||||
expect(ACPElicitation.answer(form(fields), { action: "decline" })).toBeUndefined()
|
||||
expect(ACPElicitation.answer(form(fields), { action: "cancel" })).toBeUndefined()
|
||||
expect(ACPElicitation.answer(form(fields), { action: "_custom" })).toBeUndefined()
|
||||
expect(
|
||||
ACPElicitation.answer(form(fields), { action: "accept", content: { name: { nested: true } } }),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp elicitation over the wire", () => {
|
||||
@@ -338,190 +127,4 @@ describe("acp elicitation over the wire", () => {
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("cancels forms outside the allowed flows without asking", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_plugin",
|
||||
sessionID,
|
||||
title: "Plugin",
|
||||
metadata: { kind: "plugin" },
|
||||
fields: [{ key: "name", type: "string" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
|
||||
})
|
||||
|
||||
test("cancels a question that looks like it asks for a credential with a message for the model", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_token",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Setup",
|
||||
description: "Paste your API key",
|
||||
type: "string",
|
||||
options: [{ value: "Later", label: "Later" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([
|
||||
{ sessionID: acp.sessionId, formID: "frm_token", message: ACPElicitation.UnshownQuestionMessage },
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels other forms it cannot show without a message", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_websearch",
|
||||
sessionID,
|
||||
title: "Web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [{ key: "provider", type: "string", options: [{ value: "exa", label: "Exa" }] }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_websearch" }])
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
questions(sessionID, "frm_pending"),
|
||||
questions(sessionID, "frm_queued"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
elicitation: pendingUntilAborted,
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
await acp.until(() => acp.server.cancelledForms.length === 2, "form cancellation")
|
||||
expect(acp.elicitations).toHaveLength(1)
|
||||
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(["frm_pending", "frm_queued"])
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
|
||||
})
|
||||
|
||||
test("withdraws an elicitation for a form settled elsewhere and moves on to the next ask", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID, "frm_elsewhere")],
|
||||
elicitation: (request, signal) =>
|
||||
acp.elicitations.length === 1 ? pendingUntilAborted(request, signal) : accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
|
||||
acp.server.send(
|
||||
ephemeralEvent("form.replied", { sessionID: acp.sessionId, id: "frm_elsewhere", answer: { q0: "Bun" } }),
|
||||
questions(acp.sessionId, "frm_next"),
|
||||
)
|
||||
|
||||
expect((await prompt).stopReason).toBe("end_turn")
|
||||
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
|
||||
expect(acp.elicitations).toHaveLength(2)
|
||||
expect(acp.server.repliedForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_next", answer: { q0: "Node" } }])
|
||||
expect(acp.server.cancelledForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("leaves the session alone when the user's answer arrives after the form settled", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID)],
|
||||
elicitation: () => accept({ q0: "Bun" }),
|
||||
fetch: (request) => {
|
||||
if (!request.path.endsWith("/form/frm_question/reply")) return undefined
|
||||
acp.server.send(succeeded(acp.sessionId))
|
||||
return Response.json(
|
||||
{ _tag: "FormAlreadySettledError", id: "frm_question", message: "Form already settled: frm_question" },
|
||||
{ status: 409 },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.requests.filter((request) => request.path.endsWith("/reply"))).toHaveLength(1)
|
||||
expect(acp.server.cancelledForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("prefixes a foreground child form's tool call and message with the child", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
questions("ses_child", "frm_child", "call_child"),
|
||||
],
|
||||
elicitation: () => accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toMatchObject([
|
||||
{ sessionId: acp.sessionId, toolCallId: "ses_child:call_child", message: "Review code: Questions" },
|
||||
])
|
||||
expect(acp.server.repliedForms).toEqual([{ sessionID: "ses_child", formID: "frm_child", answer: { q0: "Node" } }])
|
||||
})
|
||||
|
||||
test("omits the tool call when the child's tool calls only reach the client as child updates", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true, childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
questions("ses_child", "frm_child", "call_child"),
|
||||
],
|
||||
elicitation: () => accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toHaveLength(1)
|
||||
expect(acp.elicitations[0]).toMatchObject({ sessionId: acp.sessionId, message: "Review code: Questions" })
|
||||
expect(acp.elicitations[0]).not.toHaveProperty("toolCallId")
|
||||
})
|
||||
})
|
||||
@@ -1,55 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Cause } from "effect"
|
||||
import { ACPError } from "../../src/acp/error"
|
||||
import { rpcError, startSession, startWire } from "./wire-fixture"
|
||||
import { delivered, rpcError, startSession, textDelta, type Wire } from "./wire-fixture"
|
||||
|
||||
describe("acp errors", () => {
|
||||
test("maps validation failures to invalid params", () => {
|
||||
const cases: ACPError.Error[] = [
|
||||
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
|
||||
new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
|
||||
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
|
||||
new ACPError.InvalidEffortError({ effort: "extreme" }),
|
||||
new ACPError.InvalidModeError({ mode: "turbo" }),
|
||||
new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" }),
|
||||
]
|
||||
|
||||
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
|
||||
-32602, -32602, -32602, -32602, -32602, -32602,
|
||||
])
|
||||
})
|
||||
|
||||
test("includes safe validation details", () => {
|
||||
expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: "ses_123" },
|
||||
})
|
||||
expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { modelId: "gpt-missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps auth required to the SDK auth error", () => {
|
||||
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError())
|
||||
|
||||
expect(requestError).toBeInstanceOf(RequestError)
|
||||
expect(requestError.code).toBe(-32000)
|
||||
expect(requestError.message).toBe("Authentication required: provider authentication required")
|
||||
expect(requestError.data).toEqual({})
|
||||
})
|
||||
|
||||
test("maps service failures to safe internal errors", () => {
|
||||
const requestError = ACPError.toRequestError(
|
||||
new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
|
||||
)
|
||||
|
||||
expect(requestError.code).toBe(-32603)
|
||||
expect(requestError.message).toBe("Internal error: Provider request failed")
|
||||
expect(requestError.data).toEqual({ service: "provider" })
|
||||
})
|
||||
|
||||
test("wraps unknown defects without leaking raw details", () => {
|
||||
const requestError = ACPError.toRequestError(
|
||||
ACPError.fromUnknown(new Error("stack has sk-ant-secret and oauth refresh token")),
|
||||
@@ -65,51 +18,34 @@ describe("acp errors", () => {
|
||||
})
|
||||
|
||||
describe("acp error boundary over the wire", () => {
|
||||
test("maps unexpected server failures to the generic internal error", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.method === "POST" && request.path === "/api/session" ? new Response(null, { status: 500 }) : undefined,
|
||||
})
|
||||
await acp.initialize()
|
||||
test.each<[string, (acp: Wire) => void, string[]]>([
|
||||
["ends", (acp) => acp.server.closeEvents(), []],
|
||||
[
|
||||
"drops its connection",
|
||||
(acp) => acp.server.dropEvents(),
|
||||
["ACP catalog event stream failed", "ACP selection event stream failed"],
|
||||
],
|
||||
])(
|
||||
"reports an unavailable server when the event stream %s mid-turn and once the server stops",
|
||||
async (_, lose, logs) => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), textDelta(sessionID, "msg_held", "working")],
|
||||
})
|
||||
const unavailable = {
|
||||
code: -32603,
|
||||
message: "Internal error: OpenCode server is unavailable",
|
||||
data: { errorName: "ServerUnavailable" },
|
||||
}
|
||||
|
||||
expect(await rpcError(acp.newSession())).toEqual({
|
||||
code: -32603,
|
||||
message: "Internal error: Internal service failure",
|
||||
data: { errorName: "ClientError" },
|
||||
})
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP request failed"], cause: { _tag: "ClientError" } },
|
||||
])
|
||||
})
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
|
||||
await acp.request("session/set_mode", { sessionId: acp.sessionId, modeId: "build" })
|
||||
lose(acp)
|
||||
|
||||
test("maps rejected prompt submissions to invalid params with the server's message", async () => {
|
||||
await using acp = await startSession({
|
||||
fetch: (request) =>
|
||||
request.method === "POST" && request.path.endsWith("/prompt")
|
||||
? Response.json(
|
||||
{ _tag: "InvalidRequestError", message: "File not readable: missing.png", field: "files" },
|
||||
{ status: 400 },
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toEqual({
|
||||
code: -32602,
|
||||
message: "Invalid params: File not readable: missing.png",
|
||||
data: { field: "files" },
|
||||
})
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("reports an unavailable server once the server stops", async () => {
|
||||
await using acp = await startSession()
|
||||
await acp.server.stop()
|
||||
|
||||
expect(await rpcError(acp.request("session/list", {}))).toEqual({
|
||||
code: -32603,
|
||||
message: "Internal error: OpenCode server is unavailable",
|
||||
data: { errorName: "ServerUnavailable" },
|
||||
})
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
expect(await rpcError(prompt)).toEqual(unavailable)
|
||||
await acp.server.stop()
|
||||
expect(await rpcError(acp.request("session/list", {}))).toEqual(unavailable)
|
||||
expect(acp.logs.map((log) => String(log.message)).toSorted()).toEqual(logs)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize } from "./subprocess"
|
||||
|
||||
describe("acp initialize/auth subprocess", () => {
|
||||
test("initialize responds with capabilities", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const initialized = await initialize(fixture.spawn())
|
||||
|
||||
expect(initialized.protocolVersion).toBe(1)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
expect(initialized.agentInfo?.name).toBe("OpenCode")
|
||||
}, 60_000)
|
||||
|
||||
test("auth negotiation is explicit and safe", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const secret = "subprocess-auth-secret"
|
||||
const acp = fixture.spawn({ OPENCODE_AUTH_CONTENT: secret })
|
||||
const initialized = await initialize(acp)
|
||||
|
||||
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
|
||||
expect(expectOk(await acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" }))).toEqual(
|
||||
{},
|
||||
)
|
||||
|
||||
const rejected = await acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
|
||||
expect(rejected.error?.code).toBe(-32602)
|
||||
expect(JSON.stringify(rejected.error)).not.toContain(secret)
|
||||
}, 60_000)
|
||||
|
||||
test("initialize without terminal-auth metadata keeps auth command implicit", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const initialized = expectOk(
|
||||
await fixture.spawn().request<InitializeResponse>("initialize", { protocolVersion: 1 }),
|
||||
)
|
||||
|
||||
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,13 +1,5 @@
|
||||
import type {
|
||||
CloseSessionResponse,
|
||||
DeleteSessionResponse,
|
||||
ListSessionsResponse,
|
||||
LoadSessionResponse,
|
||||
ResumeSessionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { selectConfigOption } from "./select-options"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
import { createAcpFixture, initialize } from "./subprocess"
|
||||
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
@@ -17,103 +9,6 @@ describe("acp lifecycle subprocess", () => {
|
||||
expect(await acp.close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("an incoming message over the size limit exits with an error", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const [code] = await Promise.all([
|
||||
acp.exited,
|
||||
// The agent stops reading partway through the line, so the write may fail.
|
||||
acp.notify("opencode/oversized", { data: "a".repeat(32 * 1024 * 1024) }).catch(() => undefined),
|
||||
])
|
||||
await acp[Symbol.asyncDispose]()
|
||||
expect(code).toBe(1)
|
||||
expect(acp.stderr()).toContain("opencode acp: incoming message exceeded the 32 MiB limit\n")
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
const initialized = await initialize(acp)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
|
||||
const session = await newSession(acp, fixture.home)
|
||||
expect(
|
||||
expectOk(await acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId })),
|
||||
).toEqual({})
|
||||
}, 60_000)
|
||||
|
||||
test("new session succeeds on the first request", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
|
||||
expect((await newSession(acp, fixture.home)).sessionId).toStartWith("ses_")
|
||||
}, 60_000)
|
||||
|
||||
test("loadSession capability and load request return session config options", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
const initialized = await initialize(acp)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const loaded = expectOk(
|
||||
await acp.request<LoadSessionResponse>("session/load", {
|
||||
cwd: fixture.home,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
|
||||
const mismatched = await acp.request<LoadSessionResponse>("session/load", {
|
||||
cwd: fixture.root,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
})
|
||||
expect(mismatched.error?.code).toBe(-32602)
|
||||
}, 60_000)
|
||||
|
||||
test("list request includes a live ACP-created session", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
|
||||
|
||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
|
||||
}, 60_000)
|
||||
|
||||
test("delete capability and delete request", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
const initialized = await initialize(acp)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
expect(
|
||||
expectOk(await acp.request<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId })),
|
||||
).toEqual({})
|
||||
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
|
||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
test("resume request returns session config options", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const resumed = expectOk(
|
||||
await acp.request<ResumeSessionResponse>("session/resume", {
|
||||
cwd: fixture.home,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
|
||||
}, 60_000)
|
||||
|
||||
// The private server is found with `pgrep`, which Windows lacks.
|
||||
const testOutsideWindows = process.platform === "win32" ? test.skip : test
|
||||
testOutsideWindows(
|
||||
@@ -122,7 +17,6 @@ describe("acp lifecycle subprocess", () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
await newSession(acp, fixture.home)
|
||||
const servers = Bun.spawnSync(["pgrep", "-P", String(acp.pid)])
|
||||
.stdout.toString()
|
||||
.split("\n")
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import type { AnyRequest, CreateElicitationResponse, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Cause } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
interrupted,
|
||||
permissionAsked,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolCalled,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
turn,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
@@ -27,6 +23,15 @@ const allowOnce = () => ({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
|
||||
describe("acp permissions over the wire", () => {
|
||||
test("forwards allow-once and allow-always selections to the server", async () => {
|
||||
const selections: Record<string, () => RequestPermissionResponse> = {
|
||||
call_once: allowOnce,
|
||||
call_always: () => ({ outcome: { outcome: "selected", optionId: "always" } }),
|
||||
perm_selected_reject: () => ({ outcome: { outcome: "selected", optionId: "reject" } }),
|
||||
perm_cancelled: () => ({ outcome: { outcome: "cancelled" } }),
|
||||
perm_failed: () => {
|
||||
throw new Error("client permission UI failed")
|
||||
},
|
||||
}
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
@@ -46,13 +51,14 @@ describe("acp permissions over the wire", () => {
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_selected_reject"),
|
||||
permissionAsked(sessionID, "perm_cancelled"),
|
||||
permissionAsked(sessionID, "perm_failed"),
|
||||
),
|
||||
permission: (request) => ({
|
||||
outcome: { outcome: "selected", optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always" },
|
||||
}),
|
||||
permission: (request) => selections[request.toolCall.toolCallId](),
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
@@ -84,485 +90,170 @@ describe("acp permissions over the wire", () => {
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_once", "once"],
|
||||
["perm_always", "always"],
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves external directory permission context", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_external", {
|
||||
action: "external_directory",
|
||||
metadata: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "/tmp/outside",
|
||||
locations: [{ path: "/tmp/outside/a.ts" }],
|
||||
})
|
||||
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
|
||||
})
|
||||
|
||||
test("locates path resources without glob patterns when the ask has no tool locations", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_external", {
|
||||
action: "external_directory",
|
||||
resources: ["/tmp/outside/*", "/tmp/other/*", "/tmp/outside/*"],
|
||||
metadata: {},
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_read", {
|
||||
action: "read",
|
||||
resources: ["src/[slug].ts", "src/{a,b}.ts", "**/*.ts", "src/?.ts"],
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_search", { action: "websearch", resources: ["acp spec"] }),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.locations)).toEqual([
|
||||
[{ path: "/tmp/outside" }, { path: "/tmp/other" }],
|
||||
[{ path: path.resolve("/workspace", "src/[slug].ts") }, { path: path.resolve("/workspace", "src/{a,b}.ts") }],
|
||||
[],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks under the permission ID when ACP does not track the asking tool call", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_untracked", {
|
||||
source: { type: "tool", messageID: "msg_tools", id: "call_untracked" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_untracked"])
|
||||
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
|
||||
})
|
||||
|
||||
test("asks about a tracked tool call with its own input", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_read", "read"),
|
||||
toolCalled(sessionID, "call_read", { path: "/workspace/file.ts" }),
|
||||
permissionAsked(sessionID, "perm_read", {
|
||||
action: "read",
|
||||
metadata: { files: ["file.ts"] },
|
||||
source: { type: "tool", messageID: "msg_read", id: "call_read" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const running = acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.status === "in_progress"
|
||||
? [item.update.rawInput]
|
||||
: [],
|
||||
)
|
||||
expect(running).toEqual([{ path: "/workspace/file.ts" }])
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({ toolCallId: "call_read", rawInput: running[0] })
|
||||
})
|
||||
|
||||
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
toolStarted("ses_child", "call_child", "read"),
|
||||
toolCalled("ses_child", "call_child", { path: "/workspace/child.ts" }),
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
action: "read",
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const childMeta = {
|
||||
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Review code" },
|
||||
}
|
||||
expect(acp.permissions).toHaveLength(1)
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts", _meta: childMeta },
|
||||
})
|
||||
expect(
|
||||
acp.updates.flatMap((item) => (item.update.sessionUpdate === "tool_call" ? [item.update._meta] : [])),
|
||||
).toEqual([childMeta])
|
||||
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
|
||||
})
|
||||
|
||||
test("asks for a background child's permission after the parent turn ends without the child capability", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => turn(sessionID, id, childCreated("ses_background", sessionID, "Research")),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
acp.server.send(
|
||||
durableEvent("session.execution.started", { sessionID: "ses_background" }),
|
||||
permissionAsked("ses_background", "perm_background", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/notes.md" },
|
||||
}),
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
|
||||
|
||||
const childMeta = {
|
||||
"opencode/child-session": { id: "ses_background", parentID: acp.sessionId, depth: 1, title: "Research" },
|
||||
}
|
||||
expect(acp.permissions).toMatchObject([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "ses_background:perm_background",
|
||||
title: "Research: /workspace/notes.md",
|
||||
_meta: childMeta,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(acp.server.replies).toEqual([
|
||||
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_selected_reject"),
|
||||
permissionAsked(sessionID, "perm_cancelled"),
|
||||
permissionAsked(sessionID, "perm_failed"),
|
||||
),
|
||||
permission(request) {
|
||||
if (request.toolCall.toolCallId === "perm_selected_reject") {
|
||||
return { outcome: { outcome: "selected", optionId: "reject" } }
|
||||
}
|
||||
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
|
||||
throw new Error("client permission UI failed")
|
||||
},
|
||||
})
|
||||
|
||||
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_selected_reject", "reject"],
|
||||
["perm_cancelled", "reject"],
|
||||
["perm_failed", "reject"],
|
||||
])
|
||||
})
|
||||
|
||||
test("logs a failed server reply and still answers later asks", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, permissionAsked(sessionID, "perm_failed"), permissionAsked(sessionID, "perm_next")),
|
||||
fetch: (request) =>
|
||||
request.path.endsWith("/permission/perm_failed/reply") ? new Response(null, { status: 500 }) : undefined,
|
||||
permission: allowOnce,
|
||||
})
|
||||
test.each<[string, string, string, (sessionID: string, id: string) => OpenCodeEventEncoded]>([
|
||||
["session/request_permission", "perm", "reject", (sessionID, id) => permissionAsked(sessionID, id)],
|
||||
["elicitation/create", "frm", "cancelled", (sessionID, id) => question(sessionID, id)],
|
||||
])(
|
||||
"cancelling the turn cancels its pending %s and settles queued asks without sending them",
|
||||
async (method, prefix, outcome, ask) => {
|
||||
const pendingUntilAborted =
|
||||
<Response>(cancelled: Response) =>
|
||||
(_request: unknown, signal: AbortSignal) =>
|
||||
new Promise<Response>((resolve) => signal.addEventListener("abort", () => resolve(cancelled), { once: true }))
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ask(sessionID, `${prefix}_pending`),
|
||||
ask(sessionID, `${prefix}_queued`),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
permission: pendingUntilAborted<RequestPermissionResponse>({ outcome: { outcome: "cancelled" } }),
|
||||
elicitation: pendingUntilAborted<CreateElicitationResponse>({ action: "cancel" }),
|
||||
})
|
||||
|
||||
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_failed", "perm_next"])
|
||||
expect(decisions(acp)).toEqual([["perm_next", "once"]])
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP permission reply failed"], cause: { _tag: "ClientError" } },
|
||||
])
|
||||
})
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length + acp.elicitations.length === 1, "the pending ask")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
test("serializes permission requests and replies within one session", async () => {
|
||||
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, permissionAsked(sessionID, "perm_1"), permissionAsked(sessionID, "perm_2")),
|
||||
permission: (request) =>
|
||||
request.toolCall.toolCallId === "perm_1"
|
||||
? releaseFirst.promise
|
||||
: { outcome: { outcome: "selected", optionId: "always" } },
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length === 1, "first permission")
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
|
||||
expect(acp.server.replies).toEqual([])
|
||||
|
||||
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
await prompt
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_1", "once"],
|
||||
["perm_2", "always"],
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps streaming other children while one child's permission is pending", async () => {
|
||||
const release = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_a", sessionID, "A"),
|
||||
childCreated("ses_b", sessionID, "B"),
|
||||
permissionAsked("ses_a", "perm_a"),
|
||||
textDelta("ses_b", "msg_b", "still streaming"),
|
||||
succeeded("ses_b"),
|
||||
succeeded("ses_a"),
|
||||
),
|
||||
permission: () => release.promise,
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk", "child B's chunk")
|
||||
await acp.until(() => acp.permissions.length === 1, "child A's permission")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["ses_a:perm_a"])
|
||||
expect(acp.server.replies).toEqual([])
|
||||
release.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
expect((await prompt).stopReason).toBe("end_turn")
|
||||
expect(decisions(acp)).toEqual([["perm_a", "once"]])
|
||||
})
|
||||
|
||||
test("does not let one session's blocked permission stall another session", async () => {
|
||||
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startWire({ onPrompt: () => undefined, permission: () => releaseBlocked.promise })
|
||||
await acp.initialize()
|
||||
const blockedSession = await acp.newSession()
|
||||
const freeSession = await acp.newSession()
|
||||
|
||||
const blocked = acp.prompt(blockedSession.sessionId, "hello")
|
||||
const free = acp.prompt(freeSession.sessionId, "hello")
|
||||
const [blockedPrompt, freePrompt] = await acp.until(
|
||||
() => acp.server.prompts.length === 2 && acp.server.prompts,
|
||||
"both prompt submissions",
|
||||
)
|
||||
acp.server.send(
|
||||
delivered(blockedPrompt.sessionID, blockedPrompt.id),
|
||||
delivered(freePrompt.sessionID, freePrompt.id),
|
||||
permissionAsked(blockedPrompt.sessionID, "perm_blocked"),
|
||||
textDelta(freePrompt.sessionID, "msg_free", "session B continued"),
|
||||
stepEnded(freePrompt.sessionID, "msg_free"),
|
||||
succeeded(freePrompt.sessionID),
|
||||
succeeded(blockedPrompt.sessionID),
|
||||
)
|
||||
await acp.until(() => acp.permissions.length === 1, "blocked permission")
|
||||
|
||||
expect(await free).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.updates).toContainEqual({
|
||||
sessionId: freePrompt.sessionID,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_free",
|
||||
content: { type: "text", text: "session B continued" },
|
||||
},
|
||||
})
|
||||
expect(acp.server.replies).toEqual([])
|
||||
|
||||
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
expect(await blocked).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(decisions(acp)).toEqual([["perm_blocked", "once"]])
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), permissionAsked(sessionID, "perm_cancel")],
|
||||
onPermissionReply: ({ sessionID }) => [interrupted(sessionID)],
|
||||
permission: (_request, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
||||
}),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length === 1, "permission request")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(decisions(acp)).toEqual([["perm_cancel", "reject"]])
|
||||
const asked = acp.received.find(
|
||||
(message): message is AnyRequest =>
|
||||
"method" in message && "id" in message && message.method === "session/request_permission",
|
||||
)
|
||||
expect(acp.received).toContainEqual({
|
||||
jsonrpc: "2.0",
|
||||
method: "$/cancel_request",
|
||||
params: { requestId: asked?.id },
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects asks queued behind a cancelled one without sending them to the client", async () => {
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
const settled = () => [
|
||||
...acp.server.replies.map((reply) => [reply.requestID, reply.decision]),
|
||||
...acp.server.cancelledForms.map((form) => [form.formID, "cancelled"]),
|
||||
]
|
||||
await acp.until(() => settled().length === 2, "both asks settled")
|
||||
expect(settled()).toEqual([
|
||||
[`${prefix}_pending`, outcome],
|
||||
[`${prefix}_queued`, outcome],
|
||||
])
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
const asked = acp.received.filter(
|
||||
(message): message is AnyRequest => "method" in message && "id" in message && message.method === method,
|
||||
)
|
||||
expect(asked).toHaveLength(1)
|
||||
expect(acp.received).toContainEqual({
|
||||
jsonrpc: "2.0",
|
||||
method: "$/cancel_request",
|
||||
params: { requestId: asked[0]?.id },
|
||||
})
|
||||
},
|
||||
)
|
||||
test("withdraws permission requests settled elsewhere and still asks the remaining one", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
permissionAsked(sessionID, "perm_pending"),
|
||||
permissionAsked(sessionID, "perm_elsewhere"),
|
||||
permissionAsked(sessionID, "perm_queued"),
|
||||
permissionAsked(sessionID, "perm_remaining"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
fetch: (request) => {
|
||||
if (request.path.endsWith("/permission/perm_remaining/reply")) acp.server.send(succeeded(acp.sessionId))
|
||||
return undefined
|
||||
},
|
||||
permission: (_request, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
||||
}),
|
||||
acp.permissions.length === 1
|
||||
? new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
||||
})
|
||||
: allowOnce(),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length === 1, "permission request")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
acp.server.send(
|
||||
ephemeralEvent("permission.replied", { sessionID: acp.sessionId, requestID: "perm_queued", reply: "always" }),
|
||||
ephemeralEvent("permission.replied", { sessionID: acp.sessionId, requestID: "perm_elsewhere", reply: "always" }),
|
||||
)
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_pending"])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_pending", "reject"],
|
||||
["perm_queued", "reject"],
|
||||
])
|
||||
expect(await prompt).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_elsewhere", "perm_remaining"])
|
||||
expect(decisions(acp)).toEqual([["perm_remaining", "once"]])
|
||||
const cancels = acp.received.filter((message) => "method" in message && message.method === "$/cancel_request")
|
||||
expect(cancels).toHaveLength(1)
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp edit previews over the wire", () => {
|
||||
test("previews core's edit diff during approval", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "one\r\ntwo\r\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" }),
|
||||
permissionAsked(sessionID, "perm_edit", {
|
||||
action: "edit",
|
||||
resources: ["file.ts"],
|
||||
metadata: { files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: file }],
|
||||
content: [{ type: "diff", path: file, oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("previews each file in a patch", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
|
||||
fs.writeFile(path.join(dir.path, "second.ts"), "alpha\n"),
|
||||
])
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: second.ts",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
permissionAsked(sessionID, "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: async ({ sessionID }) => {
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(dir.path, "first.ts"), "two\n"),
|
||||
fs.writeFile(path.join(dir.path, "second.ts"), "beta\n"),
|
||||
])
|
||||
return [
|
||||
toolSucceeded(sessionID, "call_patch", { files: [{ file: "first.ts" }, { file: "second.ts" }] }, "patched"),
|
||||
succeeded(sessionID),
|
||||
]
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "2 files",
|
||||
kind: "edit",
|
||||
locations: [{ path: path.join(dir.path, "first.ts") }, { path: path.join(dir.path, "second.ts") }],
|
||||
content: [
|
||||
{ type: "diff", path: path.join(dir.path, "first.ts"), oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: path.join(dir.path, "second.ts"), oldText: "alpha\n", newText: "beta\n" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("previews added files as new and deleted files as empty", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "gone.ts"), "gone\n")
|
||||
const patchText = ["*** Begin Patch", "*** Add File: added.ts", "+one", "*** End Patch"].join("\n")
|
||||
await Promise.all(
|
||||
Object.entries({
|
||||
"file.ts": "one\r\ntwo\r\n",
|
||||
"first.ts": "one\n",
|
||||
"second.ts": "alpha\n",
|
||||
"gone.ts": "gone\n",
|
||||
"stale.ts": "changed\n",
|
||||
"unpatched.ts": "changed\n",
|
||||
}).map(([name, content]) => fs.writeFile(path.join(dir.path, name), content)),
|
||||
)
|
||||
await fs.mkdir(path.join(dir.path, "folder"))
|
||||
const file = (name: string) => path.join(dir.path, name)
|
||||
const patch = (...lines: string[]) => ["*** Begin Patch", ...lines, "*** End Patch"].join("\n")
|
||||
const edit = (sessionID: string, call: string, tool: string, input: Record<string, unknown>, metadata = {}) => [
|
||||
toolStarted(sessionID, call, tool),
|
||||
toolCalled(sessionID, call, input),
|
||||
permissionAsked(sessionID, `perm_${call}`, {
|
||||
action: "edit",
|
||||
metadata,
|
||||
source: { type: "tool", messageID: "msg_edit", id: call },
|
||||
}),
|
||||
]
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_write", "write"),
|
||||
toolCalled(sessionID, "call_write", { path: "written.ts", content: "two\n" }),
|
||||
permissionAsked(sessionID, "perm_write", {
|
||||
action: "edit",
|
||||
metadata: { files: [fileDiff("written.ts", "", "two\n", "added")] },
|
||||
source: { type: "tool", messageID: "msg_write", id: "call_write" },
|
||||
}),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
permissionAsked(sessionID, "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
...edit(
|
||||
sessionID,
|
||||
"call_edit",
|
||||
"edit",
|
||||
{ path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" },
|
||||
{ files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
|
||||
),
|
||||
...edit(sessionID, "call_patch", "patch", {
|
||||
patchText: patch(
|
||||
"*** Update File: first.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: second.ts",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
),
|
||||
}),
|
||||
...edit(
|
||||
sessionID,
|
||||
"call_write",
|
||||
"write",
|
||||
{ path: "written.ts", content: "two\n" },
|
||||
{ files: [fileDiff("written.ts", "", "two\n", "added")] },
|
||||
),
|
||||
...edit(sessionID, "call_add", "patch", { patchText: patch("*** Add File: added.ts", "+one") }),
|
||||
permissionAsked(sessionID, "perm_delete", {
|
||||
action: "edit",
|
||||
metadata: { files: [fileDiff("gone.ts", "gone\n", "", "deleted")] },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_stale", {
|
||||
action: "edit",
|
||||
resources: ["stale.ts"],
|
||||
metadata: { files: [fileDiff("stale.ts", "one\n", "two\n")] },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_folder", {
|
||||
action: "edit",
|
||||
resources: ["folder"],
|
||||
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
|
||||
}),
|
||||
...edit(sessionID, "call_unpatched", "patch", {
|
||||
patchText: patch("*** Update File: unpatched.ts", "@@", "-one", "+two"),
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
@@ -572,165 +263,43 @@ describe("acp edit previews over the wire", () => {
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([
|
||||
[{ type: "diff", path: path.join(dir.path, "written.ts"), oldText: null, newText: "two\n" }],
|
||||
[{ type: "diff", path: path.join(dir.path, "added.ts"), oldText: null, newText: "one\n" }],
|
||||
[{ type: "diff", path: path.join(dir.path, "gone.ts"), oldText: "gone\n", newText: "" }],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks without previews when core's diff no longer applies or the file cannot be read", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "file.ts"), "changed\n")
|
||||
await fs.mkdir(path.join(dir.path, "folder"))
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_stale", {
|
||||
action: "edit",
|
||||
resources: ["file.ts"],
|
||||
metadata: { files: [fileDiff("file.ts", "one\n", "two\n")] },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_folder", {
|
||||
action: "edit",
|
||||
resources: ["folder"],
|
||||
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall)).toMatchObject([
|
||||
{ kind: "edit", locations: [{ path: path.join(dir.path, "file.ts") }] },
|
||||
{ kind: "edit", locations: [{ path: path.join(dir.path, "folder") }] },
|
||||
])
|
||||
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([undefined, undefined])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_stale", "once"],
|
||||
["perm_folder", "once"],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks without previews when a patch does not apply to the current file", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
|
||||
const patchText = ["*** Begin Patch", "*** Update File: first.ts", "@@", "-one", "+two", "*** End Patch"].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
permissionAsked(sessionID, "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
[{ type: "diff", path: file("file.ts"), oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
|
||||
[
|
||||
{ type: "diff", path: file("first.ts"), oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: file("second.ts"), oldText: "alpha\n", newText: "beta\n" },
|
||||
],
|
||||
onPermissionReply: ({ sessionID }) => [succeeded(sessionID)],
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
kind: "edit",
|
||||
locations: [{ path: path.join(dir.path, "first.ts") }],
|
||||
})
|
||||
expect(acp.permissions[0]?.toolCall.content).toBeUndefined()
|
||||
expect(decisions(acp)).toEqual([["perm_patch", "once"]])
|
||||
})
|
||||
|
||||
test("reports the same absolute locations for a moved file in the permission and tool updates", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "old.ts"), "one\n")
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: old.ts",
|
||||
"*** Move to: new.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_move", "patch"),
|
||||
toolCalled(sessionID, "call_move", { patchText }),
|
||||
permissionAsked(sessionID, "perm_move", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_move", id: "call_move" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: ({ sessionID }) => [
|
||||
toolSucceeded(sessionID, "call_move", {}, "patched"),
|
||||
succeeded(sessionID),
|
||||
],
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
const locations = [{ path: path.join(dir.path, "old.ts") }, { path: path.join(dir.path, "new.ts") }]
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
locations,
|
||||
content: [{ type: "diff", path: path.join(dir.path, "new.ts"), oldText: "one\n", newText: "two\n" }],
|
||||
})
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.toolCallId === "call_move"
|
||||
? [[item.update.status, item.update.locations]]
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
["in_progress", locations],
|
||||
["completed", locations],
|
||||
[{ type: "diff", path: file("written.ts"), oldText: null, newText: "two\n" }],
|
||||
[{ type: "diff", path: file("added.ts"), oldText: null, newText: "one\n" }],
|
||||
[{ type: "diff", path: file("gone.ts"), oldText: "gone\n", newText: "" }],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
})
|
||||
|
||||
test("does not echo completed edits to a client that advertises writeTextFile", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "after")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { filePath: file, oldString: "before", newString: "after" }),
|
||||
toolSucceeded(sessionID, "call_edit", { files: [{ file }] }, "edited"),
|
||||
),
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.writes).toEqual([])
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed" ? [item.update] : [],
|
||||
),
|
||||
).toMatchObject([
|
||||
{
|
||||
toolCallId: "call_edit",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "edited" } },
|
||||
{ type: "diff", path: file, oldText: "before", newText: "after" },
|
||||
],
|
||||
},
|
||||
expect(acp.permissions.slice(0, 2).map((request) => request.toolCall)).toMatchObject([
|
||||
{ title: "file.ts", kind: "edit", locations: [{ path: file("file.ts") }] },
|
||||
{ title: "2 files", kind: "edit", locations: [{ path: file("first.ts") }, { path: file("second.ts") }] },
|
||||
])
|
||||
expect(acp.permissions.slice(5).map((request) => request.toolCall.locations)).toEqual([
|
||||
[{ path: file("stale.ts") }],
|
||||
[{ path: file("folder") }],
|
||||
[{ path: file("unpatched.ts") }],
|
||||
])
|
||||
expect(decisions(acp)).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
|
||||
function question(sessionID: string, id: string) {
|
||||
return ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id,
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "q0", title: "Runtime", type: "string", options: [{ value: "Bun", label: "Bun" }] }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function decisions(acp: Wire) {
|
||||
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StopReason } from "@agentclientprotocol/sdk"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import { Schema } from "effect"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
@@ -7,21 +7,26 @@ import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
makeSession,
|
||||
reasoningDelta,
|
||||
reviewCommand,
|
||||
rpcError,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
toolCalled,
|
||||
toolFailed,
|
||||
toolProgress,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
turn,
|
||||
type Wire,
|
||||
type WireOptions,
|
||||
@@ -36,156 +41,8 @@ const held = {
|
||||
|
||||
describe("acp prompt turns over the wire", () => {
|
||||
test("streams an admitted turn and resolves with usage after its terminal event", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_assistant", "hello"), stepEnded(sessionID, "msg_assistant")),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hi")
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
_meta: {},
|
||||
})
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "agent_message_chunk")).toEqual([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_assistant",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{
|
||||
kind: "prompt",
|
||||
sessionID: acp.sessionId,
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "hi",
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes slash commands and compact through their session endpoints", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
const command = await acp.prompt(acp.sessionId, "/review now")
|
||||
const compact = await acp.prompt(acp.sessionId, "/compact")
|
||||
|
||||
expect([command.stopReason, compact.stopReason]).toEqual(["end_turn", "end_turn"])
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{ kind: "command", sessionID: acp.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
|
||||
{ kind: "compact", sessionID: acp.sessionId, id: expect.stringMatching(/^msg_/) },
|
||||
])
|
||||
})
|
||||
|
||||
test("submits assistant-only context as synthetic input before the visible prompt", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const readme = pathToFileURL(path.join(dir.path, "README.md")).href
|
||||
await Bun.write(path.join(dir.path, "README.md"), "# readme\n")
|
||||
await using acp = await startSession()
|
||||
|
||||
await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "visible" },
|
||||
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
|
||||
{ type: "resource_link", uri: readme, name: "README.md", mimeType: "text/markdown" },
|
||||
])
|
||||
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{
|
||||
kind: "synthetic",
|
||||
sessionID: acp.sessionId,
|
||||
text: "hidden context",
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: "visible",
|
||||
files: [{ uri: readme, name: "README.md" }],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("attaches readable file links and references unreadable ones in place", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = pathToFileURL(path.join(dir.path, "notes.md")).href
|
||||
const folder = pathToFileURL(path.join(dir.path, "src")).href
|
||||
const missing = pathToFileURL(path.join(dir.path, "missing.md")).href
|
||||
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
|
||||
await mkdir(path.join(dir.path, "src"))
|
||||
await using acp = await startSession()
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "compare" },
|
||||
{ type: "resource_link", uri: file, name: "notes.md" },
|
||||
{ type: "resource_link", uri: missing, name: "missing.md" },
|
||||
{ type: "resource_link", uri: folder, name: "src" },
|
||||
{ type: "text", text: "please" },
|
||||
])
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: `compare\n[missing.md](${missing})\nplease`,
|
||||
files: [
|
||||
{ uri: file, name: "notes.md" },
|
||||
{ uri: folder, name: "src" },
|
||||
],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("attaches local image links and references remote or unreadable ones in place", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const local = pathToFileURL(path.join(dir.path, "local.png")).href
|
||||
const missing = pathToFileURL(path.join(dir.path, "missing.png")).href
|
||||
await Bun.write(path.join(dir.path, "local.png"), "png")
|
||||
await using acp = await startSession()
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "compare" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/remote.png" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: local },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: missing },
|
||||
])
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: `compare\n[remote.png](https://example.com/remote.png)\n[missing.png](${missing})`,
|
||||
files: [{ uri: local, name: "local.png" }],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects an image with neither data nor uri as invalid params", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image", data: "", mimeType: "image/png" },
|
||||
]),
|
||||
),
|
||||
).toMatchObject({ code: -32602, message: expect.stringContaining("image content has no data or uri") })
|
||||
expect(acp.server.submissions).toEqual([])
|
||||
})
|
||||
|
||||
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
|
||||
const assistantTokens = { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } }
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, stepEnded(sessionID, "msg_assistant", { tokens: assistantTokens })),
|
||||
})
|
||||
const releaseAdmission = Promise.withResolvers<void>()
|
||||
await using acp = await startSession({ onPrompt: () => releaseAdmission.promise })
|
||||
await acp.request("session/set_config_option", {
|
||||
sessionId: acp.sessionId,
|
||||
configId: "model",
|
||||
@@ -193,9 +50,38 @@ describe("acp prompt turns over the wire", () => {
|
||||
})
|
||||
acp.server.sessions.set(acp.sessionId, makeSession(acp.sessionId, { cost: 3.5 }))
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
const prompt = acp.prompt(acp.sessionId, "hi")
|
||||
const settled = { value: false }
|
||||
void prompt.finally(() => {
|
||||
settled.value = true
|
||||
})
|
||||
const submitted = await acp.until(() => acp.server.prompts[0], "prompt submission")
|
||||
const sessionID = acp.sessionId
|
||||
acp.server.send(
|
||||
...turn(
|
||||
sessionID,
|
||||
submitted.id,
|
||||
reasoningDelta(sessionID, "msg_assistant", "think-1"),
|
||||
reasoningDelta(sessionID, "msg_assistant", " continued"),
|
||||
textDelta(sessionID, "msg_assistant", "hello", 1),
|
||||
reasoningDelta(sessionID, "msg_assistant", "think-2", 1),
|
||||
toolStarted(sessionID, "call_ok", "shell"),
|
||||
toolCalled(sessionID, "call_ok", { command: "printf done", workdir: "sub" }),
|
||||
toolProgress(sessionID, "call_ok", { phase: 1 }),
|
||||
toolSucceeded(sessionID, "call_ok", { exit: 0 }, "done"),
|
||||
toolStarted(sessionID, "call_fail", "read"),
|
||||
toolCalled(sessionID, "call_fail", { path: "/workspace/missing.ts" }),
|
||||
toolFailed(sessionID, "call_fail", { error: { type: "tool.error", message: "not found" } }),
|
||||
stepEnded(sessionID, "msg_assistant", {
|
||||
tokens: { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
await acp.until(() => turnUpdates(acp.updates).length === 11, "streamed updates")
|
||||
expect(settled.value).toBe(false)
|
||||
releaseAdmission.resolve()
|
||||
|
||||
expect(response).toEqual({
|
||||
expect(await prompt).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
@@ -207,144 +93,169 @@ describe("acp prompt turns over the wire", () => {
|
||||
},
|
||||
_meta: {},
|
||||
})
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{ kind: "prompt", sessionID, id: expect.stringMatching(/^msg_/), text: "hi", files: [], delivery: "steer" },
|
||||
])
|
||||
expect(
|
||||
turnUpdates(acp.updates).map((item) => [
|
||||
item.update.sessionUpdate,
|
||||
"toolCallId" in item.update
|
||||
? item.update.toolCallId
|
||||
: "messageId" in item.update
|
||||
? item.update.messageId
|
||||
: undefined,
|
||||
"status" in item.update ? item.update.status : undefined,
|
||||
]),
|
||||
).toEqual([
|
||||
["agent_thought_chunk", "msg_assistant:reasoning:0", undefined],
|
||||
["agent_thought_chunk", "msg_assistant:reasoning:0", undefined],
|
||||
["agent_message_chunk", "msg_assistant", undefined],
|
||||
["agent_thought_chunk", "msg_assistant:reasoning:1", undefined],
|
||||
["tool_call", "call_ok", "pending"],
|
||||
["tool_call_update", "call_ok", "in_progress"],
|
||||
["tool_call_update", "call_ok", "in_progress"],
|
||||
["tool_call_update", "call_ok", "completed"],
|
||||
["tool_call", "call_fail", "pending"],
|
||||
["tool_call_update", "call_fail", "in_progress"],
|
||||
["tool_call_update", "call_fail", "failed"],
|
||||
])
|
||||
expect(turnUpdates(acp.updates)[2]?.update).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_assistant",
|
||||
content: { type: "text", text: "hello" },
|
||||
})
|
||||
expect(turnUpdates(acp.updates)[5]?.update).toMatchObject({
|
||||
title: "printf done",
|
||||
kind: "execute",
|
||||
locations: [{ path: path.resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
})
|
||||
expect(turnUpdates(acp.updates)[7]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(turnUpdates(acp.updates)[10]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "not found" } }],
|
||||
rawOutput: { error: "not found" },
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
|
||||
sessionId: acp.sessionId,
|
||||
sessionId: sessionID,
|
||||
update: { sessionUpdate: "usage_update", used: 171, size: 200_000, cost: { amount: 3.5, currency: "USD" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("completes the prompt when reads after admission fail", async () => {
|
||||
const reads = { failing: false }
|
||||
await using acp = await startSession({
|
||||
fetch: (request) => (reads.failing && request.method === "GET" ? new Response(null, { status: 500 }) : undefined),
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
reads.failing = true
|
||||
return turn(sessionID, id, stepEnded(sessionID, "msg_assistant"))
|
||||
},
|
||||
})
|
||||
test("routes slash commands and compact through their session endpoints", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "compact", description: "Server compact" }]
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession()
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
})
|
||||
const command = await acp.prompt(session.sessionId, "/review now")
|
||||
const compact = await acp.prompt(session.sessionId, "/compact")
|
||||
|
||||
test.each<{ name: string; stopReason: StopReason; events: (sessionID: string) => OpenCodeEventEncoded[] }>([
|
||||
{ name: "a normal stop", stopReason: "end_turn", events: (id) => [stepEnded(id, "msg_1"), succeeded(id)] },
|
||||
{
|
||||
name: "a length-limited step",
|
||||
stopReason: "max_tokens",
|
||||
events: (id) => [stepEnded(id, "msg_1", { finish: "length" }), succeeded(id)],
|
||||
},
|
||||
{
|
||||
name: "a content-filtered step",
|
||||
stopReason: "refusal",
|
||||
events: (id) => [stepEnded(id, "msg_1", { finish: "content-filter" }), succeeded(id)],
|
||||
},
|
||||
{
|
||||
name: "a content-filter failure",
|
||||
stopReason: "refusal",
|
||||
events: (id) => [failed(id, { type: "provider.content-filter", message: "blocked" })],
|
||||
},
|
||||
{
|
||||
name: "a server-side interruption",
|
||||
stopReason: "cancelled",
|
||||
events: (id) => [durableEvent("session.execution.interrupted", { sessionID: id, reason: "shutdown" })],
|
||||
},
|
||||
])("maps $name to $stopReason", async (input) => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...input.events(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe(input.stopReason)
|
||||
})
|
||||
|
||||
test("maps provider auth failures to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
failed(sessionID, { type: "provider.auth", message: "missing key" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("maps an assistant step auth error to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
textDelta(sessionID, "msg_auth", "partial"),
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_auth",
|
||||
error: { type: "provider.auth", message: "expired" },
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("surfaces other execution failures as internal errors with the failure message", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
failed(sessionID, { type: "provider.rate-limit", message: "slow down" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({
|
||||
code: -32603,
|
||||
message: expect.stringContaining("slow down"),
|
||||
})
|
||||
})
|
||||
|
||||
test("reports provider retries while pending and clears them when the next step starts", async () => {
|
||||
const at = Date.UTC(2026, 0, 1)
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
retryScheduled(sessionID, 2, at),
|
||||
durableEvent("session.step.started", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_retry",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
started: at,
|
||||
}),
|
||||
stepEnded(sessionID, "msg_retry"),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "session_info_update")).toEqual([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": retryMeta(2, at) } },
|
||||
},
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": null } },
|
||||
},
|
||||
expect([command.stopReason, compact.stopReason]).toEqual(["end_turn", "end_turn"])
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{ kind: "command", sessionID: session.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
|
||||
{ kind: "compact", sessionID: session.sessionId, id: expect.stringMatching(/^msg_/) },
|
||||
])
|
||||
expect(response._meta).toEqual({})
|
||||
expect(
|
||||
(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).update,
|
||||
).toEqual({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("reports the pending retry on a turn cancelled during backoff", async () => {
|
||||
const at = Date.UTC(2026, 0, 1)
|
||||
test("attaches readable file links and references unreadable ones in place", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = pathToFileURL(path.join(dir.path, "notes.md")).href
|
||||
const folder = pathToFileURL(path.join(dir.path, "src")).href
|
||||
const missing = pathToFileURL(path.join(dir.path, "missing.md")).href
|
||||
const image = pathToFileURL(path.join(dir.path, "local.png")).href
|
||||
const missingImage = pathToFileURL(path.join(dir.path, "missing.png")).href
|
||||
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
|
||||
await Bun.write(path.join(dir.path, "local.png"), "png")
|
||||
await mkdir(path.join(dir.path, "src"))
|
||||
await using acp = await startSession()
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "compare" },
|
||||
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
|
||||
{ type: "resource_link", uri: file, name: "notes.md" },
|
||||
{ type: "resource_link", uri: missing, name: "missing.md" },
|
||||
{ type: "resource_link", uri: folder, name: "src" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/remote.png" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: image },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: missingImage },
|
||||
{ type: "text", text: "please" },
|
||||
])
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{
|
||||
kind: "synthetic",
|
||||
sessionID: acp.sessionId,
|
||||
text: "hidden context",
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: `compare\n[missing.md](${missing})\n[remote.png](https://example.com/remote.png)\n[missing.png](${missingImage})\nplease`,
|
||||
files: [
|
||||
{ uri: file, name: "notes.md" },
|
||||
{ uri: folder, name: "src" },
|
||||
{ uri: image, name: "local.png" },
|
||||
],
|
||||
}),
|
||||
])
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image", data: "", mimeType: "image/png" },
|
||||
]),
|
||||
),
|
||||
).toMatchObject({ code: -32602, message: expect.stringContaining("image content has no data or uri") })
|
||||
expect(acp.server.submissions).toHaveLength(2)
|
||||
})
|
||||
|
||||
test.each<[string, (sessionID: string) => OpenCodeEventEncoded, object]>([
|
||||
[
|
||||
"an execution auth failure",
|
||||
(sessionID) => failed(sessionID, { type: "provider.auth", message: "missing key" }),
|
||||
{ code: -32000 },
|
||||
],
|
||||
[
|
||||
"a step auth failure",
|
||||
(sessionID) =>
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_auth",
|
||||
error: { type: "provider.auth", message: "expired" },
|
||||
}),
|
||||
{ code: -32000 },
|
||||
],
|
||||
[
|
||||
"another execution failure",
|
||||
(sessionID) => failed(sessionID, { type: "provider.rate-limit", message: "slow down" }),
|
||||
{ code: -32603, message: expect.stringContaining("slow down") },
|
||||
],
|
||||
])("maps %s to an ACP error", async (_, failure, expected) => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), retryScheduled(sessionID, 1, at)],
|
||||
onInterrupt: held.onInterrupt,
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
textDelta(sessionID, "msg_auth", "partial"),
|
||||
failure(sessionID),
|
||||
succeeded(sessionID),
|
||||
],
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: { "opencode/retry": retryMeta(1, at) } })
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject(expected)
|
||||
})
|
||||
|
||||
test("session/cancel before admission aborts the submission and returns cancelled", async () => {
|
||||
@@ -369,45 +280,29 @@ describe("acp prompt turns over the wire", () => {
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
await aborted.promise
|
||||
expect(acp.server.interrupts).toContain(acp.sessionId)
|
||||
})
|
||||
|
||||
test("session/cancel before admission returns interrupts the session exactly once", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ signal }) =>
|
||||
new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.server.submissions.length === 1, "prompt submission")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
})
|
||||
|
||||
test("session/cancel mid-turn interrupts the session once, returns cancelled, and keeps it usable", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await admitted(acp, acp.sessionId)
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("session/cancel forwards the server's wind-down before resolving cancelled", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_sleep", "shell"),
|
||||
toolCalled(sessionID, "call_sleep", { command: "sleep 60" }),
|
||||
textDelta(sessionID, "msg_held", "working"),
|
||||
],
|
||||
capabilities: { compaction: {} },
|
||||
onPrompt: ({ sessionID, id, text }) =>
|
||||
text === "again"
|
||||
? turn(sessionID, id)
|
||||
: [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_sleep", "shell"),
|
||||
toolCalled(sessionID, "call_sleep", { command: "sleep 60" }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
textDelta(sessionID, "msg_held", "working"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [
|
||||
toolFailed(sessionID, "call_sleep", { error: { type: "aborted", message: "interrupted" } }),
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_held",
|
||||
@@ -428,22 +323,31 @@ describe("acp prompt turns over the wire", () => {
|
||||
usage: { inputTokens: 30, outputTokens: 3, totalTokens: 33 },
|
||||
_meta: {},
|
||||
})
|
||||
expect(receivedBeforeResponse(acp)).toEqual(
|
||||
const before = receivedBeforeResponse(acp)
|
||||
expect(before).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ sessionUpdate: "tool_call_update", toolCallId: "call_sleep", status: "failed" }),
|
||||
expect.objectContaining({ sessionUpdate: "usage_update", used: 33 }),
|
||||
]),
|
||||
)
|
||||
expect(before.filter((update) => update.sessionUpdate === "compaction_update")).toMatchObject([
|
||||
{ status: "in_progress" },
|
||||
{ status: "cancelled", summary: null },
|
||||
])
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("stops waiting for a wind-down that never ends and fails the tools left running", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { compaction: {} },
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_stuck", "shell"),
|
||||
toolCalled(sessionID, "call_stuck", { command: "sleep 60" }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
|
||||
textDelta(sessionID, "msg_held", "working"),
|
||||
],
|
||||
})
|
||||
@@ -453,7 +357,9 @@ describe("acp prompt turns over the wire", () => {
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
expect(receivedBeforeResponse(acp)).toContainEqual(
|
||||
const compactionId = acp.server.prompts[0]?.id
|
||||
const before = receivedBeforeResponse(acp)
|
||||
expect(before).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call_stuck",
|
||||
@@ -461,37 +367,11 @@ describe("acp prompt turns over the wire", () => {
|
||||
rawOutput: expect.objectContaining({ error: "Cancelled" }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("session/close interrupts a slash command still running after its prompt ended", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "/review now")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
await acp.request("session/close", { sessionId: acp.sessionId })
|
||||
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
})
|
||||
|
||||
test("fails the prompt as server unavailable when the event stream ends mid-turn", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await admitted(acp, acp.sessionId)
|
||||
acp.server.closeEvents()
|
||||
|
||||
expect(await rpcError(prompt)).toMatchObject({ code: -32603, data: { errorName: "ServerUnavailable" } })
|
||||
})
|
||||
|
||||
test("fails the prompt as server unavailable when the event stream's connection drops mid-turn", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await admitted(acp, acp.sessionId)
|
||||
acp.server.dropEvents()
|
||||
|
||||
expect(await rpcError(prompt)).toMatchObject({ code: -32603, data: { errorName: "ServerUnavailable" } })
|
||||
expect(acp.logs).toEqual([])
|
||||
expect(before.filter((update) => update.sessionUpdate.startsWith("compaction_"))).toEqual([
|
||||
{ sessionUpdate: "compaction_update", compactionId, status: "in_progress" },
|
||||
{ sessionUpdate: "compaction_summary_chunk", compactionId, content: { type: "text", text: "partial" } },
|
||||
{ sessionUpdate: "compaction_update", compactionId, status: "cancelled", summary: null },
|
||||
])
|
||||
})
|
||||
|
||||
test("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
|
||||
@@ -531,123 +411,10 @@ describe("acp prompt turns over the wire", () => {
|
||||
data: { sessionId: acp.sessionId },
|
||||
})
|
||||
expect((await acp.prompt(other.sessionId, "still here")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("rejects a second prompt while the session already has an active turn", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const first = acp.prompt(acp.sessionId, "hold")
|
||||
await acp.until(() => acp.server.submissions.length === 1, "first prompt")
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "second"))).toMatchObject({ code: -32603 })
|
||||
expect(acp.server.submissions).toHaveLength(1)
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
expect((await first).stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
test("reports usage summed across every step of the turn", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
|
||||
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
|
||||
})
|
||||
|
||||
test("publishes the last step's context usage rather than the turn sum", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
stepEnded(sessionID, "msg_step_1", {
|
||||
finish: "tool-calls",
|
||||
tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 50 } },
|
||||
}),
|
||||
stepEnded(sessionID, "msg_step_2", {
|
||||
tokens: { input: 20, output: 5, reasoning: 3, cache: { read: 150, write: 0 } },
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({
|
||||
inputTokens: 120,
|
||||
outputTokens: 15,
|
||||
thoughtTokens: 3,
|
||||
cachedReadTokens: 150,
|
||||
cachedWriteTokens: 50,
|
||||
totalTokens: 338,
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "usage_update", used: 178, size: 100_000, cost: { amount: 0, currency: "USD" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("counts a failed step's tokens and clears its error when the next step starts", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_1",
|
||||
error: { type: "provider.stream", message: "stream interrupted" },
|
||||
cost: 0,
|
||||
tokens: { ...tokens(), input: 40, output: 4 },
|
||||
}),
|
||||
durableEvent("session.step.started", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_2",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
started: 0,
|
||||
}),
|
||||
stepEnded(sessionID, "msg_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 60, outputTokens: 11, totalTokens: 71 },
|
||||
_meta: {},
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
|
||||
update: { used: 27 },
|
||||
})
|
||||
})
|
||||
|
||||
test("excludes child session steps from the turn usage", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
stepEnded("ses_child", "msg_child", { tokens: { ...tokens(), input: 500, output: 50 } }),
|
||||
succeeded("ses_child"),
|
||||
stepEnded(sessionID, "msg_root", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 20, outputTokens: 7, totalTokens: 27 })
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
|
||||
update: { used: 27 },
|
||||
})
|
||||
expect((await acp.prompt(other.sessionId, "/review now")).stopReason).toBe("end_turn")
|
||||
await acp.request("session/close", { sessionId: other.sessionId })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId, other.sessionId])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -672,20 +439,8 @@ function receivedBeforeResponse(acp: Wire) {
|
||||
return acp.updates.slice(0, count).map((item) => item.update)
|
||||
}
|
||||
|
||||
function retryScheduled(sessionID: string, attempt: number, at: number) {
|
||||
return durableEvent("session.retry.scheduled", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_retry",
|
||||
attempt,
|
||||
at,
|
||||
error: { type: "provider.rate-limit", message: "rate limited" },
|
||||
})
|
||||
}
|
||||
|
||||
function retryMeta(attempt: number, at: number) {
|
||||
return {
|
||||
attempt,
|
||||
nextRetryAt: new Date(at).toISOString(),
|
||||
error: { type: "provider.rate-limit", message: "rate limited" },
|
||||
}
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
return updates.filter(
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Option, Schema } from "effect"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import { createAcpFixture, expectOk, initialize, newSession, toolCallStream } from "./subprocess"
|
||||
|
||||
const ChatRequest = Schema.Struct({
|
||||
messages: Schema.Array(
|
||||
Schema.Struct({ role: Schema.String, tool_call_id: Schema.optional(Schema.String), content: Schema.Unknown }),
|
||||
),
|
||||
})
|
||||
|
||||
// The first completion asks a question; the follow-up completion ends the turn.
|
||||
function askingModel(request: unknown) {
|
||||
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
|
||||
return toolCallStream("call_question", "question", {
|
||||
questions: [
|
||||
{
|
||||
header: "Runtime",
|
||||
question: "Which runtime?",
|
||||
options: [
|
||||
{ label: "Bun", description: "Fast" },
|
||||
{ label: "Node", description: "Stable" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe("acp question subprocess", () => {
|
||||
test("a question the client cannot show returns to the model and the turn continues", async () => {
|
||||
await using fixture = await createAcpFixture({ respond: askingModel })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const result = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "ask me something" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.stopReason).toBe("end_turn")
|
||||
const results = fixture.llm.requests
|
||||
.flatMap((request) => Option.toArray(Schema.decodeUnknownOption(ChatRequest)(request)))
|
||||
.flatMap((request) => request.messages)
|
||||
.filter((message) => message.role === "tool" && message.tool_call_id === "call_question")
|
||||
expect(results).toHaveLength(1)
|
||||
expect(String(results[0]?.content)).toContain(ACPElicitation.UnshownQuestionMessage)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { assistantMessage, makeSession, rpcError, startWire } from "./wire-fixture"
|
||||
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp session replay over the wire", () => {
|
||||
test("replays user, text, reasoning, and tool messages in order on session/load", async () => {
|
||||
@@ -59,50 +59,8 @@ describe("acp session replay over the wire", () => {
|
||||
})
|
||||
expect(updates[11]?.update).toMatchObject({ toolCallId: "call_streaming", status: "pending", rawInput: {} })
|
||||
})
|
||||
|
||||
test("fails the load and detaches when a message page does not decode", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch(request) {
|
||||
if (request.path !== "/api/session/ses_replay_failure/message") return undefined
|
||||
return Response.json({
|
||||
data: [
|
||||
// An errored tool without its error does not decode.
|
||||
replayToolMessage("call_first", { status: "error", input: {}, metadata: {} }),
|
||||
replayToolMessage("call_after", {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
}),
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
},
|
||||
})
|
||||
acp.server.sessions.set("ses_replay_failure", makeSession("ses_replay_failure"))
|
||||
await acp.initialize()
|
||||
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/load", { cwd: "/workspace", sessionId: "ses_replay_failure", mcpServers: [] }),
|
||||
),
|
||||
).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate !== "available_commands_update")).toEqual([])
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: "ses_replay_failure", configId: "mode", value: "plan" }),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_replay_failure" } })
|
||||
})
|
||||
})
|
||||
|
||||
function replayToolMessage(id: string, state: Record<string, unknown>) {
|
||||
return {
|
||||
...assistantMessage(`msg_${id}`),
|
||||
content: [{ type: "tool", id, name: "shell", time: { created: 1, completed: 2 }, state }],
|
||||
}
|
||||
}
|
||||
|
||||
function replayFixtureMessages(): Array<typeof SessionMessage.Info.Encoded> {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp retry subprocess", () => {
|
||||
test("reports provider retries during the turn and on cancellation", async () => {
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: () =>
|
||||
Response.json(
|
||||
{ error: { message: "rate-limited upstream", type: "rate_limit_error" } },
|
||||
// A long backoff keeps the retry pending until the cancel lands.
|
||||
{ status: 429, headers: { "retry-after": "30" } },
|
||||
),
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const prompt = acp.send<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
const scheduled = await acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) => params.sessionId === session.sessionId && params.update.sessionUpdate === "session_info_update",
|
||||
)
|
||||
const retry = scheduled.params.update._meta?.["opencode/retry"]
|
||||
expect(retry).toMatchObject({ attempt: 2, error: { message: expect.stringContaining("rate-limited upstream") } })
|
||||
|
||||
await acp.notify("session/cancel", { sessionId: session.sessionId })
|
||||
const response = expectOk(await prompt.response)
|
||||
expect(response.stopReason).toBe("cancelled")
|
||||
expect(response._meta?.["opencode/retry"]).toEqual(retry)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,30 +1,4 @@
|
||||
import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"
|
||||
|
||||
type SelectOption = Extract<SessionConfigOption, { type: "select" }>
|
||||
|
||||
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
return options?.find((option): option is SelectOption => option.id === id && option.type === "select")
|
||||
}
|
||||
|
||||
export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
const option = selectConfigOption(options, id)
|
||||
if (!option) throw new Error(`Missing ACP config option: ${id}`)
|
||||
return option
|
||||
}
|
||||
|
||||
export function flattenSelectOptions(option: SelectOption) {
|
||||
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
|
||||
}
|
||||
|
||||
export function selectValues(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
return flattenSelectOptions(requireSelectOption(options, id)).map((option) => option.value)
|
||||
}
|
||||
|
||||
export function alternateValue(option: SelectOption) {
|
||||
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
|
||||
if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
|
||||
return value
|
||||
}
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
|
||||
export function currentValue(
|
||||
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { currentValue, selectValues } from "./select-options"
|
||||
import {
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
secondModel,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
testModel,
|
||||
turn,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
|
||||
describe("acp follows model and agent selections from other clients", () => {
|
||||
test("pushes an external model switch with its variant and uses it for the next prompt", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => turn(sessionID, id, stepEnded(sessionID, "msg_assistant")),
|
||||
})
|
||||
|
||||
acp.server.send(
|
||||
durableEvent("session.model.selected", {
|
||||
sessionID: acp.sessionId,
|
||||
model: { providerID: "test", id: secondModel.id, variant: "low" },
|
||||
previous: { providerID: "test", id: "test-model" },
|
||||
}),
|
||||
)
|
||||
const [options] = await optionUpdates(acp, acp.sessionId, 1)
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(values(options)).toEqual({ model: "test/second-model", effort: "low", mode: "build" })
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
|
||||
update: { size: secondModel.limit.context },
|
||||
})
|
||||
})
|
||||
|
||||
test("shows the default effort when the external switch carries no variant", async () => {
|
||||
await using acp = await startSession()
|
||||
await acp.request("session/set_config_option", { sessionId: acp.sessionId, configId: "effort", value: "high" })
|
||||
|
||||
acp.server.send(
|
||||
durableEvent("session.model.selected", {
|
||||
sessionID: acp.sessionId,
|
||||
model: { providerID: "test", id: secondModel.id },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(values((await optionUpdates(acp, acp.sessionId, 1))[0])).toEqual({
|
||||
model: "test/second-model",
|
||||
effort: "default",
|
||||
mode: "build",
|
||||
})
|
||||
})
|
||||
|
||||
test("pushes an external agent switch as the mode", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
acp.server.send(
|
||||
durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan", previous: "build" }),
|
||||
)
|
||||
|
||||
expect(values((await optionUpdates(acp, acp.sessionId, 1))[0])).toEqual({
|
||||
model: "test/test-model",
|
||||
effort: "default",
|
||||
mode: "plan",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not echo its own model and mode switches", async () => {
|
||||
await using acp = await startSession({
|
||||
// The server publishes the selection event before it answers the switch.
|
||||
fetch: (request) => {
|
||||
if (request.method !== "POST") return undefined
|
||||
if (request.path.endsWith("/model"))
|
||||
acp.server.send(
|
||||
durableEvent("session.model.selected", {
|
||||
sessionID: acp.sessionId,
|
||||
model: { providerID: "test", id: secondModel.id, variant: "medium" },
|
||||
}),
|
||||
)
|
||||
if (request.path.endsWith("/agent"))
|
||||
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan" }))
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const set = (configId: string, value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId: acp.sessionId, configId, value })
|
||||
|
||||
await set("model", "test/second-model/medium")
|
||||
await acp.request("session/set_mode", { sessionId: acp.sessionId, modeId: "plan" })
|
||||
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "build" }))
|
||||
const updates = await optionUpdates(acp, acp.sessionId, 1)
|
||||
|
||||
expect(updates.map(values)).toEqual([{ model: "test/second-model", effort: "medium", mode: "build" }])
|
||||
})
|
||||
|
||||
test("ignores selections for other sessions", async () => {
|
||||
await using acp = await startSession()
|
||||
const other = await acp.newSession()
|
||||
|
||||
acp.server.send(durableEvent("session.agent.selected", { sessionID: "ses_unattached", agent: "plan" }))
|
||||
acp.server.send(durableEvent("session.agent.selected", { sessionID: other.sessionId, agent: "plan" }))
|
||||
acp.server.send(
|
||||
durableEvent("session.model.selected", {
|
||||
sessionID: acp.sessionId,
|
||||
model: { providerID: "test", id: secondModel.id },
|
||||
}),
|
||||
)
|
||||
const updates = await optionUpdates(acp, acp.sessionId, 1)
|
||||
|
||||
expect(updates.map(values)).toEqual([{ model: "test/second-model", effort: "default", mode: "build" }])
|
||||
})
|
||||
|
||||
test("stops following a closed session and follows it once after re-attaching", async () => {
|
||||
await using acp = await startSession()
|
||||
const other = await acp.newSession()
|
||||
await acp.request("session/close", { sessionId: acp.sessionId })
|
||||
|
||||
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan" }))
|
||||
acp.server.send(durableEvent("session.agent.selected", { sessionID: other.sessionId, agent: "plan" }))
|
||||
await optionUpdates(acp, other.sessionId, 1)
|
||||
expect(configUpdates(acp, acp.sessionId)).toEqual([])
|
||||
|
||||
const stored = acp.server.sessions.get(acp.sessionId)
|
||||
if (!stored) throw new Error(`missing stored session ${acp.sessionId}`)
|
||||
acp.server.sessions.set(acp.sessionId, { ...stored, agent: "plan" })
|
||||
const resume = () => acp.request("session/resume", { sessionId: acp.sessionId, cwd: "/workspace" })
|
||||
const resumed = await resume()
|
||||
await resume()
|
||||
acp.server.send(
|
||||
durableEvent("session.model.selected", {
|
||||
sessionID: acp.sessionId,
|
||||
model: { providerID: "test", id: secondModel.id },
|
||||
}),
|
||||
)
|
||||
await optionUpdates(acp, acp.sessionId, 1)
|
||||
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "build" }))
|
||||
await optionUpdates(acp, acp.sessionId, 2)
|
||||
|
||||
expect(currentValue(resumed, "mode")).toBe("plan")
|
||||
expect(configUpdates(acp, acp.sessionId).map(values)).toEqual([
|
||||
{ model: "test/second-model", effort: "default", mode: "plan" },
|
||||
{ model: "test/second-model", effort: "default", mode: "build" },
|
||||
])
|
||||
})
|
||||
|
||||
test("ends on the latest catalog and selection when both change together", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const { sessionId } = await acp.newSession()
|
||||
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.send(
|
||||
ephemeralEvent("model.updated", {}),
|
||||
durableEvent("session.model.selected", {
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "test", id: secondModel.id },
|
||||
}),
|
||||
)
|
||||
await acp.until(
|
||||
() => configUpdates(acp, sessionId).some((options) => selectValues(options, "model").length === 2),
|
||||
"the reloaded catalog",
|
||||
)
|
||||
acp.server.send(durableEvent("session.agent.selected", { sessionID: sessionId, agent: "plan" }))
|
||||
const updates = await acp.until(() => {
|
||||
const updates = configUpdates(acp, sessionId)
|
||||
return values(updates.at(-1)).mode === "plan" && updates
|
||||
}, "the sentinel update")
|
||||
|
||||
expect(
|
||||
updates.slice(-2).map((options) => ({ ...values(options), models: selectValues(options, "model") })),
|
||||
).toEqual(
|
||||
["build", "plan"].map((mode) => ({
|
||||
model: "test/second-model",
|
||||
effort: "default",
|
||||
mode,
|
||||
models: ["test/second-model", "test/test-model"],
|
||||
})),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function configUpdates(acp: Wire, sessionId: string) {
|
||||
return acp.updates.flatMap((item) =>
|
||||
item.sessionId === sessionId && item.update.sessionUpdate === "config_option_update"
|
||||
? [item.update.configOptions]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function optionUpdates(acp: Wire, sessionId: string, count: number) {
|
||||
return acp.until(() => {
|
||||
const updates = configUpdates(acp, sessionId)
|
||||
return updates.length >= count && updates
|
||||
}, `${count} config_option_update for ${sessionId}`)
|
||||
}
|
||||
|
||||
function values(options: SessionConfigOption[] | undefined) {
|
||||
return Object.fromEntries((options ?? []).map((option) => [option.id, option.currentValue]))
|
||||
}
|
||||
@@ -1,20 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import { currentValue } from "./select-options"
|
||||
import {
|
||||
ephemeralEvent,
|
||||
makeSession,
|
||||
rpcError,
|
||||
secondModel,
|
||||
startSession,
|
||||
startWire,
|
||||
testModel,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
import { makeSession, rpcError, secondModel, startWire, type Wire } from "./wire-fixture"
|
||||
|
||||
describe("acp session lifecycle over the wire", () => {
|
||||
test("initialize advertises capabilities and terminal auth only when the client asks", async () => {
|
||||
test("initialize advertises capabilities, negotiates the version, and offers terminal auth only when asked", async () => {
|
||||
await using acp = await startWire()
|
||||
|
||||
const plain = await acp.initialize()
|
||||
@@ -42,48 +33,7 @@ describe("acp session lifecycle over the wire", () => {
|
||||
code: -32602,
|
||||
data: { methodId: "missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.commands = [{ name: "review" }]
|
||||
await acp.initialize()
|
||||
|
||||
const result = await acp.newSession("/workspace", [
|
||||
{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] },
|
||||
])
|
||||
|
||||
expect(acp.server.sessions.get(result.sessionId)?.location.directory).toBe("/workspace")
|
||||
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||
expect(acp.server.mcp).toEqual([
|
||||
{
|
||||
name: "docs",
|
||||
directory: "/workspace",
|
||||
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
|
||||
},
|
||||
])
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).toEqual({
|
||||
sessionId: result.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [{ ...secondModel, variants: [{ id: "none" }, { id: "high" }] }]
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.newSession()
|
||||
|
||||
expect(currentValue(created, "effort")).toBe("default")
|
||||
expect(acp.server.sessions.get(created.sessionId)?.model).toBeUndefined()
|
||||
expect(acp.server.selections).toEqual([])
|
||||
expect((await acp.request("initialize", { protocolVersion: 99 })).protocolVersion).toBe(1)
|
||||
})
|
||||
|
||||
test("loads with paginated replay while resume and fork do not replay", async () => {
|
||||
@@ -167,156 +117,6 @@ describe("acp session lifecycle over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("does not publish commands for a session closed before its load responds", async () => {
|
||||
const held = Promise.withResolvers<undefined>()
|
||||
await using acp = await startWire({
|
||||
fetch: (request) => (request.path === "/api/session/ses_loaded/message" ? held.promise : undefined),
|
||||
})
|
||||
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
|
||||
await acp.initialize()
|
||||
|
||||
const loaded = acp.request("session/load", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })
|
||||
await acp.until(() => acp.server.requests.some((item) => item.path === "/api/session/ses_loaded/message"))
|
||||
await acp.request("session/close", { sessionId: "ses_loaded" })
|
||||
held.resolve(undefined)
|
||||
await loaded
|
||||
// A later session's commands follow the load response, giving a stray update time to arrive.
|
||||
const created = await acp.newSession()
|
||||
await acp.until(() => acp.updates.some(isCommands), "commands for the later session")
|
||||
|
||||
expect(acp.updates.filter(isCommands).map((item) => item.sessionId)).toEqual([created.sessionId])
|
||||
})
|
||||
|
||||
test("detaches a session whose load fails after attaching", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.path === "/api/session/ses_loaded/message" ? new Response(null, { status: 500 }) : undefined,
|
||||
})
|
||||
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
|
||||
await acp.initialize()
|
||||
|
||||
expect(
|
||||
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32603 })
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: "ses_loaded", configId: "mode", value: "plan" }),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded" } })
|
||||
})
|
||||
|
||||
test("fails new and resume before attaching when the catalog does not decode", async () => {
|
||||
// The client rejects a model without a name, so the catalog fails to load.
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.path === "/api/model"
|
||||
? Response.json({
|
||||
location: { directory: "/workspace", project: { id: "global", directory: "/workspace" } },
|
||||
data: [testModel, { ...secondModel, name: null }],
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
acp.server.sessions.set("ses_resumed", makeSession("ses_resumed"))
|
||||
await acp.initialize()
|
||||
const existing = new Set(acp.server.sessions.keys())
|
||||
|
||||
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
|
||||
expect(new Set(acp.server.sessions.keys())).toEqual(existing)
|
||||
|
||||
expect(
|
||||
await rpcError(acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_resumed", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: "ses_resumed", configId: "mode", value: "plan" }),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_resumed" } })
|
||||
expect(acp.updates).toEqual([])
|
||||
})
|
||||
|
||||
test("lists server-backed pages for the requested cwd", async () => {
|
||||
await using acp = await startWire()
|
||||
Array.from({ length: 101 }, (_, index) =>
|
||||
makeSession(`ses_${index}`, { time: { created: index, updated: 1_000 + index } }),
|
||||
).forEach((session) => acp.server.sessions.set(session.id, session))
|
||||
acp.server.sessions.set(
|
||||
"ses_other",
|
||||
makeSession("ses_other", { cwd: "/other", time: { created: 0, updated: 9_999 } }),
|
||||
)
|
||||
await acp.initialize()
|
||||
|
||||
const first = await acp.request("session/list", { cwd: "/workspace" })
|
||||
const second = await acp.request("session/list", { cwd: "/workspace", cursor: first.nextCursor })
|
||||
|
||||
expect(first.sessions).toHaveLength(100)
|
||||
expect(first.sessions[0]).toEqual({
|
||||
sessionId: "ses_100",
|
||||
cwd: "/workspace",
|
||||
title: "Session ses_100",
|
||||
updatedAt: new Date(1_100).toISOString(),
|
||||
})
|
||||
expect(first.nextCursor).toBeDefined()
|
||||
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
|
||||
expect(second.nextCursor).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cancel keeps an idle session attached while close detaches it", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
expect((await acp.prompt(acp.sessionId, "after cancel")).stopReason).toBe("end_turn")
|
||||
|
||||
expect(await acp.request("session/close", { sessionId: acp.sessionId })).toEqual({})
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "after close"))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: acp.sessionId },
|
||||
})
|
||||
expect(await acp.request("session/close", { sessionId: "missing" })).toEqual({})
|
||||
})
|
||||
|
||||
test("deletes sessions from backing and local storage", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
|
||||
expect(acp.server.sessions.has(acp.sessionId)).toBe(false)
|
||||
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
|
||||
expect(await acp.request("session/delete", { sessionId: "ses_never_created" })).toEqual({})
|
||||
expect(await acp.request("session/delete", { sessionId: "never-created" })).toEqual({})
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: acp.sessionId, configId: "effort", value: "high" }),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: acp.sessionId } })
|
||||
})
|
||||
|
||||
test("rejects malformed session IDs as invalid params", async () => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
const params = { cwd: "/workspace", sessionId: "never-created", mcpServers: [] }
|
||||
|
||||
const invalid = { code: -32602, message: "Invalid params: Invalid session ID", data: { field: "sessionID" } }
|
||||
|
||||
expect(await rpcError(acp.request("session/load", params))).toEqual(invalid)
|
||||
expect(await rpcError(acp.request("session/resume", params))).toEqual(invalid)
|
||||
expect(await rpcError(acp.request("session/fork", params))).toEqual(invalid)
|
||||
expect(acp.server.requests.filter((request) => request.path.includes("never-created"))).toEqual([])
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("rejects forking an unknown session as session not found", async () => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
expect(
|
||||
await rpcError(acp.request("session/fork", { cwd: "/workspace", sessionId: "ses_unknown", mcpServers: [] })),
|
||||
).toEqual({
|
||||
code: -32602,
|
||||
message: "Invalid params: session not found: ses_unknown",
|
||||
data: { sessionId: "ses_unknown" },
|
||||
})
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("converts MCP configs and deduplicates registrations per session and config", async () => {
|
||||
const local: McpServer = {
|
||||
name: "tools",
|
||||
@@ -337,54 +137,55 @@ describe("acp session lifecycle over the wire", () => {
|
||||
const first = await acp.newSession("/workspace", [local, local, remote])
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [local, remote] })
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [changed] })
|
||||
await acp.newSession("/workspace", [local])
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: first.sessionId, mcpServers: [local] })
|
||||
|
||||
const localConfig = (args: string[]) => ({
|
||||
name: "tools",
|
||||
directory: "/workspace",
|
||||
config: { type: "local", command: ["bun", ...args], environment: { TOKEN: "x" } },
|
||||
})
|
||||
expect(acp.server.mcp).toHaveLength(4)
|
||||
expect(acp.server.mcp.filter((item) => item.name === "tools")).toEqual([
|
||||
localConfig(["server.ts"]),
|
||||
localConfig(["changed.ts"]),
|
||||
localConfig(["server.ts"]),
|
||||
])
|
||||
|
||||
await acp.newSession("/workspace", [local])
|
||||
|
||||
expect(acp.server.mcp).toHaveLength(5)
|
||||
expect(acp.server.mcp.filter((item) => item.name === "tools")).toEqual([
|
||||
localConfig(["server.ts"]),
|
||||
localConfig(["changed.ts"]),
|
||||
localConfig(["server.ts"]),
|
||||
localConfig(["server.ts"]),
|
||||
])
|
||||
expect(acp.server.mcp.find((item) => item.name === "docs")).toEqual({
|
||||
name: "docs",
|
||||
directory: "/workspace",
|
||||
config: { type: "remote", url: "https://example.com/mcp", headers: { Authorization: "Bearer x" }, oauth: false },
|
||||
})
|
||||
})
|
||||
test("leaves a session detached when re-attaching it fails", async () => {
|
||||
const broken: McpServer = { name: "broken", command: "bun", args: [], env: [] }
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.method === "PUT" && request.path === "/api/experimental/mcp/broken"
|
||||
? new Response(null, { status: 500 })
|
||||
: undefined,
|
||||
})
|
||||
test("rejects MCP-over-ACP and SSE servers before creating or loading a session", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_saved", makeSession("ses_saved"))
|
||||
await acp.initialize()
|
||||
const failed = await acp.newSession()
|
||||
const other = await acp.newSession()
|
||||
await acp.until(() => acp.updates.filter(isCommands).length === 2, "initial commands")
|
||||
const existing = new Set(acp.server.sessions.keys())
|
||||
const mcpServers: McpServer[] = [{ type: "acp", name: "client", serverId: "mcp_client" }]
|
||||
const sse: McpServer[] = [{ type: "sse", name: "events", url: "https://example.com/sse", headers: [] }]
|
||||
const invalid = {
|
||||
code: -32602,
|
||||
message: "Invalid params: Only stdio and HTTP MCP servers are supported",
|
||||
data: { field: "mcpServers" },
|
||||
}
|
||||
|
||||
expect(await rpcError(acp.newSession("/workspace", mcpServers))).toEqual(invalid)
|
||||
expect(await rpcError(acp.newSession("/workspace", sse))).toEqual(invalid)
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/resume", { cwd: "/workspace", sessionId: failed.sessionId, mcpServers: [broken] }),
|
||||
),
|
||||
).toMatchObject({ code: -32603 })
|
||||
const since = acp.updates.length
|
||||
acp.server.catalog.models = [testModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
await acp.until(() => acp.updates.length > since, "config options for the attached session")
|
||||
|
||||
expect(acp.updates.slice(since).map((item) => item.sessionId)).toEqual([other.sessionId])
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: failed.sessionId, configId: "mode", value: "plan" }),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: failed.sessionId } })
|
||||
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers })),
|
||||
).toEqual(invalid)
|
||||
expect(new Set(acp.server.sessions.keys())).toEqual(existing)
|
||||
expect(acp.server.requests.filter((request) => request.path.includes("ses_saved"))).toEqual([])
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -395,10 +196,6 @@ const isSessionUpdate = Schema.is(
|
||||
}),
|
||||
)
|
||||
|
||||
function isCommands(item: SessionNotification) {
|
||||
return item.update.sessionUpdate === "available_commands_update"
|
||||
}
|
||||
|
||||
// Labels what the agent sends from the request until the commands that follow it, in wire order.
|
||||
async function untilCommands(acp: Wire, send: () => Promise<unknown>) {
|
||||
const start = acp.received.length
|
||||
|
||||
@@ -11,25 +11,19 @@ type JsonRpcRequest = {
|
||||
readonly params?: unknown
|
||||
}
|
||||
|
||||
export type JsonRpcError = {
|
||||
type JsonRpcError = {
|
||||
readonly code: number
|
||||
readonly message?: string
|
||||
readonly data?: unknown
|
||||
}
|
||||
|
||||
export type JsonRpcResponse<T> = {
|
||||
type JsonRpcResponse<T> = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly id: number
|
||||
readonly result?: T
|
||||
readonly error?: JsonRpcError
|
||||
}
|
||||
|
||||
type JsonRpcNotification<T> = {
|
||||
readonly jsonrpc: "2.0"
|
||||
readonly method: string
|
||||
readonly params: T
|
||||
}
|
||||
|
||||
type JsonRpcMessage = Record<string, unknown>
|
||||
|
||||
type Waiter = {
|
||||
@@ -39,36 +33,17 @@ type Waiter = {
|
||||
readonly timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export type AcpProcess = {
|
||||
type AcpProcess = {
|
||||
readonly pid: number
|
||||
readonly exited: Promise<number>
|
||||
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
|
||||
readonly send: <T>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
) => { readonly id: number; readonly response: Promise<JsonRpcResponse<T>> }
|
||||
readonly notify: (method: string, params: unknown) => Promise<void>
|
||||
readonly waitForNotification: <T>(
|
||||
method: string,
|
||||
predicate: (params: T) => boolean,
|
||||
timeoutMs?: number,
|
||||
) => Promise<JsonRpcNotification<T>>
|
||||
readonly close: () => Promise<number>
|
||||
readonly stderr: () => string
|
||||
readonly [Symbol.asyncDispose]: () => Promise<void>
|
||||
}
|
||||
|
||||
export const verifierSkill = `---
|
||||
name: verifier-skill
|
||||
description: Verifier compatibility skill.
|
||||
---
|
||||
|
||||
# Verifier Skill
|
||||
`
|
||||
|
||||
export async function createAcpFixture(
|
||||
options: {
|
||||
readonly skill?: string
|
||||
readonly respond?: (request: unknown) => string | Response | Promise<string | Response>
|
||||
} = {},
|
||||
) {
|
||||
@@ -76,12 +51,7 @@ export async function createAcpFixture(
|
||||
const home = path.join(root, "workspace")
|
||||
const config = path.join(root, "config")
|
||||
const models = path.join(root, "models.json")
|
||||
const skills = path.join(root, "skills")
|
||||
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
|
||||
if (options.skill) {
|
||||
await fs.mkdir(path.join(skills, "verifier-skill"), { recursive: true })
|
||||
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
|
||||
}
|
||||
|
||||
const requests: unknown[] = []
|
||||
const llm = Bun.serve({
|
||||
@@ -100,10 +70,7 @@ export async function createAcpFixture(
|
||||
})
|
||||
},
|
||||
})
|
||||
await Bun.write(
|
||||
path.join(config, "opencode.json"),
|
||||
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
|
||||
)
|
||||
await Bun.write(path.join(config, "opencode.json"), JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`)))
|
||||
await Bun.write(models, "{}")
|
||||
|
||||
const processes = new Set<AcpProcess>()
|
||||
@@ -111,7 +78,7 @@ export async function createAcpFixture(
|
||||
root,
|
||||
home,
|
||||
llm: { requests },
|
||||
spawn(extraEnv: Record<string, string | undefined> = {}) {
|
||||
spawn() {
|
||||
const acp = spawnAcp({
|
||||
env: isolatedEnv(root, {
|
||||
USERPROFILE: root,
|
||||
@@ -119,7 +86,6 @@ export async function createAcpFixture(
|
||||
OPENCODE_CONFIG_CONTENT: undefined,
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
||||
OPENCODE_MODELS_PATH: models,
|
||||
...extraEnv,
|
||||
}),
|
||||
})
|
||||
processes.add(acp)
|
||||
@@ -167,7 +133,10 @@ export function toolCallStream(id: string, name: string, input: unknown) {
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: { role: "assistant", tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
|
||||
delta: {
|
||||
role: "assistant",
|
||||
tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
@@ -185,7 +154,7 @@ export function toolCallStream(id: string, name: string, input: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function verifierConfig(llmUrl: string, skills?: string) {
|
||||
function verifierConfig(llmUrl: string) {
|
||||
const model = {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: { input: 0, output: 0 },
|
||||
@@ -194,7 +163,6 @@ function verifierConfig(llmUrl: string, skills?: string) {
|
||||
return {
|
||||
update: "disable",
|
||||
model: "test/test-model",
|
||||
...(skills ? { skills: [skills] } : {}),
|
||||
providers: {
|
||||
test: {
|
||||
name: "Test",
|
||||
@@ -305,41 +273,27 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
|
||||
})
|
||||
}
|
||||
|
||||
const write = async (message: JsonRpcRequest | JsonRpcNotification<unknown>) => {
|
||||
const write = async (message: JsonRpcRequest) => {
|
||||
if (inputClosed) throw new Error("ACP stdin is closed")
|
||||
await child.stdin.write(encoder.encode(`${JSON.stringify(message)}\n`))
|
||||
await child.stdin.flush()
|
||||
}
|
||||
|
||||
const send = <T>(method: string, params?: unknown) => {
|
||||
const request = <T>(method: string, params?: unknown) => {
|
||||
const id = nextID++
|
||||
const request: JsonRpcRequest =
|
||||
const message: JsonRpcRequest =
|
||||
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
|
||||
const response = write(request).then(async () => {
|
||||
return write(message).then(async () => {
|
||||
const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
|
||||
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
|
||||
return response
|
||||
})
|
||||
return { id, response }
|
||||
}
|
||||
|
||||
return {
|
||||
pid: child.pid,
|
||||
exited: child.exited,
|
||||
request: <T>(method: string, params?: unknown) => send<T>(method, params).response,
|
||||
send,
|
||||
notify: (method: string, params: unknown) => write({ jsonrpc: "2.0", method, params }),
|
||||
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
|
||||
const notification = await take(
|
||||
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
|
||||
timeoutMs,
|
||||
`${method} notification`,
|
||||
)
|
||||
if (!isNotification<T>(notification)) {
|
||||
throw new Error(`Invalid ACP notification: ${JSON.stringify(notification)}`)
|
||||
}
|
||||
return notification
|
||||
},
|
||||
request,
|
||||
async close() {
|
||||
if (!inputClosed) {
|
||||
inputClosed = true
|
||||
@@ -375,10 +329,6 @@ function isResponse<T>(message: JsonRpcMessage): message is JsonRpcMessage & Jso
|
||||
return message.jsonrpc === "2.0" && typeof message.id === "number" && !("method" in message)
|
||||
}
|
||||
|
||||
function isNotification<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcNotification<T> {
|
||||
return message.jsonrpc === "2.0" && typeof message.method === "string" && !("id" in message)
|
||||
}
|
||||
|
||||
function asError(error: unknown) {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
import { resolve } from "node:path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
completedToolUpdate,
|
||||
errorToolUpdate,
|
||||
pendingToolCall,
|
||||
runningToolUpdate,
|
||||
toLocations,
|
||||
toToolKind,
|
||||
} from "../../src/acp/tool"
|
||||
|
||||
describe("acp tools", () => {
|
||||
test("maps OpenCode tool ids to ACP tool kinds", () => {
|
||||
expect(toToolKind("bash")).toBe("execute")
|
||||
expect(toToolKind("shell")).toBe("execute")
|
||||
expect(toToolKind("webfetch")).toBe("fetch")
|
||||
expect(toToolKind("edit")).toBe("edit")
|
||||
expect(toToolKind("apply_patch")).toBe("edit")
|
||||
expect(toToolKind("patch")).toBe("edit")
|
||||
expect(toToolKind("write")).toBe("edit")
|
||||
expect(toToolKind("grep")).toBe("search")
|
||||
expect(toToolKind("glob")).toBe("search")
|
||||
expect(toToolKind("context7_resolve_library_id")).toBe("search")
|
||||
expect(toToolKind("context7_get_library_docs")).toBe("search")
|
||||
expect(toToolKind("read")).toBe("read")
|
||||
expect(toToolKind("task")).toBe("think")
|
||||
expect(toToolKind("custom_tool")).toBe("other")
|
||||
})
|
||||
|
||||
test("extracts file locations from tool input", () => {
|
||||
expect(toLocations("read", { path: "/tmp/a.ts" }, "/workspace")).toEqual([{ path: "/tmp/a.ts" }])
|
||||
expect(toLocations("edit", { path: "/tmp/b.ts", oldString: "a", newString: "b" }, "/workspace")).toEqual([
|
||||
{ path: "/tmp/b.ts" },
|
||||
])
|
||||
expect(toLocations("write", { path: "/tmp/c.ts", content: "c" }, "/workspace")).toEqual([{ path: "/tmp/c.ts" }])
|
||||
expect(toLocations("read", { filePath: "/tmp/v1.ts" }, "/workspace")).toEqual([{ path: "/tmp/v1.ts" }])
|
||||
expect(toLocations("edit", { path: "src/b.ts" }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "src/b.ts") },
|
||||
])
|
||||
expect(toLocations("grep", { path: "/repo/src" }, "/workspace")).toEqual([{ path: "/repo/src" }])
|
||||
expect(toLocations("glob", { path: "/repo/test" }, "/workspace")).toEqual([{ path: "/repo/test" }])
|
||||
expect(toLocations("grep", { pattern: "x", path: "src" }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "src") },
|
||||
])
|
||||
expect(toLocations("glob", { pattern: "*.ts" }, "/workspace")).toEqual([])
|
||||
expect(toLocations("context7_get_library_docs", { path: "/docs" }, "/workspace")).toEqual([{ path: "/docs" }])
|
||||
expect(
|
||||
toLocations("external_directory", { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" }, "/workspace"),
|
||||
).toEqual([{ path: "/tmp/outside/a.ts" }])
|
||||
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
|
||||
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "subdir") },
|
||||
])
|
||||
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" }, "/workspace")).toEqual([
|
||||
{ path: "/tmp/missing-file-path.ts" },
|
||||
])
|
||||
})
|
||||
|
||||
test("extracts patch locations from every hunk in the patch body", () => {
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: src/new.ts",
|
||||
"+created",
|
||||
"*** Update File: /abs/old.ts",
|
||||
"*** Move to: src/moved.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: src/same.ts",
|
||||
"@@",
|
||||
"-a",
|
||||
"+b",
|
||||
"*** Delete File: src/gone.ts",
|
||||
"*** Update File: src/new.ts",
|
||||
"@@",
|
||||
"-created",
|
||||
"+changed",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
|
||||
expect(toLocations("patch", { patchText }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "src/new.ts") },
|
||||
{ path: "/abs/old.ts" },
|
||||
{ path: resolve("/workspace", "src/moved.ts") },
|
||||
{ path: resolve("/workspace", "src/same.ts") },
|
||||
{ path: resolve("/workspace", "src/gone.ts") },
|
||||
])
|
||||
expect(toLocations("apply_patch", { patchText }, "/workspace")).toHaveLength(5)
|
||||
})
|
||||
|
||||
test("returns no patch locations when the patch body cannot be parsed", () => {
|
||||
expect(toLocations("patch", { patchText: "*** Update File: src/a.ts\n-a\n+b" }, "/workspace")).toEqual([])
|
||||
expect(toLocations("patch", { patchText: "*** Begin Patch\nnot a hunk\n*** End Patch" }, "/workspace")).toEqual([])
|
||||
expect(toLocations("patch", {}, "/workspace")).toEqual([])
|
||||
})
|
||||
|
||||
test("reports resolved locations on completed and failed updates", () => {
|
||||
const patchText = ["*** Begin Patch", "*** Add File: src/a.ts", "+a", "*** End Patch"].join("\n")
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
toolName: "patch",
|
||||
input: { patchText },
|
||||
content: [{ type: "text", text: "patched" }],
|
||||
cwd: "/workspace",
|
||||
}).locations,
|
||||
).toEqual([{ path: resolve("/workspace", "src/a.ts") }])
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
toolName: "edit",
|
||||
input: { path: "src/b.ts", oldString: "before", newString: "after" },
|
||||
content: [],
|
||||
cwd: "/workspace",
|
||||
}),
|
||||
).toMatchObject({
|
||||
locations: [{ path: resolve("/workspace", "src/b.ts") }],
|
||||
content: [{ type: "diff", path: resolve("/workspace", "src/b.ts"), oldText: "before", newText: "after" }],
|
||||
})
|
||||
expect(
|
||||
errorToolUpdate({
|
||||
toolCallId: "call",
|
||||
toolName: "write",
|
||||
input: { path: "src/c.ts", content: "c" },
|
||||
error: "denied",
|
||||
cwd: "/workspace",
|
||||
}).locations,
|
||||
).toEqual([{ path: resolve("/workspace", "src/c.ts") }])
|
||||
})
|
||||
|
||||
test("builds completed content with text and image attachments", () => {
|
||||
const image = Buffer.from("image-data").toString("base64")
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
input: {
|
||||
path: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
content: [
|
||||
{ type: "text", text: "edited /tmp/file.ts" },
|
||||
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
|
||||
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
|
||||
],
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "edited /tmp/file.ts" },
|
||||
},
|
||||
{
|
||||
type: "diff",
|
||||
path: "/tmp/file.ts",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "image", mimeType: "image/png", data: image },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("omits edit diffs when normalized content does not contain one", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "write",
|
||||
input: {
|
||||
path: "/tmp/file.ts",
|
||||
content: "created",
|
||||
},
|
||||
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "wrote /tmp/file.ts" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-read",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/file.ts" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
}).content,
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-list",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
entries: [
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "src", type: "directory" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).content,
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
|
||||
})
|
||||
|
||||
test("sends completed tool calls as partial updates", () => {
|
||||
expect(
|
||||
pendingToolCall({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
input: {
|
||||
path: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
toolCallId: "tool-1",
|
||||
status: "pending",
|
||||
kind: "edit",
|
||||
locations: [{ path: "/tmp/file.ts" }],
|
||||
rawInput: {
|
||||
path: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
input: {
|
||||
path: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
content: [{ type: "text", text: "Edit applied successfully." }],
|
||||
metadata: { output: "Edit applied successfully." },
|
||||
}),
|
||||
).toEqual({
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
locations: [{ path: "/tmp/file.ts" }],
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Edit applied successfully." },
|
||||
},
|
||||
{
|
||||
type: "diff",
|
||||
path: "/tmp/file.ts",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
metadata: { output: "Edit applied successfully." },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("builds running tool updates with normalized content", () => {
|
||||
expect(
|
||||
runningToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
state: { input: { path: "/tmp/a" } },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
}),
|
||||
).toMatchObject({
|
||||
toolCallId: "call",
|
||||
status: "in_progress",
|
||||
locations: [{ path: "/tmp/a" }],
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
})
|
||||
})
|
||||
|
||||
test("builds completed raw output with optional metadata", () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: "file",
|
||||
mime: "image/jpeg",
|
||||
name: "photo.jpg",
|
||||
uri: "data:image/jpeg;base64,AAAA",
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
}).rawOutput,
|
||||
).toEqual({
|
||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
})
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
}).rawOutput,
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
test("extracts image attachments only from data URLs", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [
|
||||
{ type: "file", mime: "image/webp", uri: "data:image/webp;charset=utf-8;base64,AAAA" },
|
||||
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
|
||||
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
|
||||
],
|
||||
metadata: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("builds failed tool updates", () => {
|
||||
expect(
|
||||
errorToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/a" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
metadata: { path: "/tmp/a" },
|
||||
error: "failed",
|
||||
}),
|
||||
).toEqual({
|
||||
toolCallId: "call",
|
||||
status: "failed",
|
||||
kind: "read",
|
||||
title: "read",
|
||||
locations: [{ path: "/tmp/a" }],
|
||||
rawInput: { path: "/tmp/a" },
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "partial output" } },
|
||||
{ type: "content", content: { type: "text", text: "failed" } },
|
||||
],
|
||||
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,20 +3,26 @@ import { OpenCodeEvent, type OpenCodeEventEncoded } from "@opencode/protocol/gro
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Schema } from "effect"
|
||||
import path from "node:path"
|
||||
import { ACPReplay } from "../../src/acp/replay"
|
||||
import { ACPTranslate } from "../../src/acp/translate"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
reasoningDelta,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
toolCalled,
|
||||
toolFailed,
|
||||
toolProgress,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const root = Session.ID.make("ses_root")
|
||||
@@ -28,57 +34,161 @@ const ctx: ACPTranslate.Context = {
|
||||
compaction: false,
|
||||
mode: "turn",
|
||||
}
|
||||
|
||||
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx, state = ACPTranslate.initial) {
|
||||
return events.reduce<{ state: ACPTranslate.TurnState; outputs: ACPTranslate.Output[]; terminal?: string }>(
|
||||
(acc, event, index) => {
|
||||
const next = ACPTranslate.step(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
|
||||
return {
|
||||
state: next.state,
|
||||
outputs: [...acc.outputs, ...next.outputs],
|
||||
...(next.terminal ? { terminal: next.terminal } : {}),
|
||||
}
|
||||
},
|
||||
{ state, outputs: [] },
|
||||
)
|
||||
}
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownSync(OpenCodeEvent)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
|
||||
function started(...events: OpenCodeEventEncoded[]) {
|
||||
return run([delivered(root, "msg_input"), ...events])
|
||||
const live = (...events: OpenCodeEventEncoded[]) => [delivered(root, "msg_input"), ...events]
|
||||
const usage = (input: number, output: number, reasoning = 0, read = 0, write = 0) => ({
|
||||
input,
|
||||
output,
|
||||
reasoning,
|
||||
cache: { read, write },
|
||||
})
|
||||
const providerError = { type: "provider.error", message: "summary request failed" }
|
||||
const compaction = {
|
||||
started: (sessionID: string = root) =>
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: "msg_compact" }),
|
||||
delta: (text: string, sessionID: string = root) => ephemeralEvent("session.compaction.delta", { sessionID, text }),
|
||||
ended: (text: string, sessionID: string = root) =>
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text, recent: "" }),
|
||||
failed: (error: { type: string; message: string }, reason: "auto" | "manual" = "manual") =>
|
||||
durableEvent("session.compaction.failed", { sessionID: root, reason, error }),
|
||||
}
|
||||
const marker = (value: Record<string, unknown>) => ({
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: { "opencode/compaction": { messageId: "msg_compact", reason: "manual", ...value } },
|
||||
})
|
||||
const childMeta = (id: string, parentID: string, depth: number, title: string) => ({
|
||||
"opencode/child-session": { id, parentID, depth, title },
|
||||
})
|
||||
const compactionMessage = (input: Record<string, unknown>) => ({
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
reason: "auto",
|
||||
time: { created: 1 },
|
||||
...input,
|
||||
})
|
||||
|
||||
type Row = {
|
||||
readonly name: string
|
||||
readonly ctx?: Partial<ACPTranslate.Context>
|
||||
readonly events?: OpenCodeEventEncoded[]
|
||||
readonly messages?: unknown[]
|
||||
readonly expected: object
|
||||
}
|
||||
|
||||
function updates(outputs: ReadonlyArray<ACPTranslate.Output>) {
|
||||
return outputs.flatMap((output) => (output._tag === "SessionUpdate" ? [output.update] : []))
|
||||
}
|
||||
|
||||
describe("acp turn translation", () => {
|
||||
test("ignores a session's events until its own input is delivered, and other sessions' events after", () => {
|
||||
const result = run([
|
||||
textDelta(root, "msg_early", "early"),
|
||||
delivered(root, "msg_other_input"),
|
||||
const rows: Row[] = [
|
||||
{
|
||||
name: "ignores a session's events until its own input is delivered",
|
||||
events: [textDelta(root, "msg_early", "early"), delivered(root, "msg_other_input"), succeeded(root)],
|
||||
expected: { updates: [], terminal: undefined },
|
||||
},
|
||||
{
|
||||
name: "ignores other sessions' events",
|
||||
events: live(
|
||||
delivered("ses_other", "msg_input"),
|
||||
succeeded(root),
|
||||
delivered(root, "msg_input"),
|
||||
textDelta("ses_other", "msg_other", "other session"),
|
||||
textDelta("ses_other", "msg_other", "other"),
|
||||
textDelta(root, "msg_ok", "accepted"),
|
||||
])
|
||||
|
||||
expect(result.terminal).toBeUndefined()
|
||||
expect(updates(result.outputs)).toEqual([
|
||||
{ sessionUpdate: "agent_message_chunk", messageId: "msg_ok", content: { type: "text", text: "accepted" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("sums usage across steps, including a failed step, and keeps the last step for context", () => {
|
||||
const result = started(
|
||||
),
|
||||
expected: { updates: [{ sessionUpdate: "agent_message_chunk", messageId: "msg_ok" }] },
|
||||
},
|
||||
{
|
||||
name: "streams text and reasoning with one message ID per reasoning ordinal",
|
||||
events: live(
|
||||
reasoningDelta(root, "msg_a", "think"),
|
||||
textDelta(root, "msg_a", "answer", 1),
|
||||
reasoningDelta(root, "msg_a", "more", 1),
|
||||
),
|
||||
expected: {
|
||||
updates: [
|
||||
{ sessionUpdate: "agent_thought_chunk", messageId: "msg_a:reasoning:0", content: { text: "think" } },
|
||||
{ sessionUpdate: "agent_message_chunk", messageId: "msg_a", content: { text: "answer" } },
|
||||
{ sessionUpdate: "agent_thought_chunk", messageId: "msg_a:reasoning:1", content: { text: "more" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ends a normal turn with end_turn",
|
||||
events: live(stepEnded(root, "msg_1"), succeeded(root)),
|
||||
expected: { response: { stopReason: "end_turn", usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } } },
|
||||
},
|
||||
{
|
||||
name: "ends a length-limited step with max_tokens",
|
||||
events: live(stepEnded(root, "msg_1", { finish: "length" }), succeeded(root)),
|
||||
expected: { response: { stopReason: "max_tokens" } },
|
||||
},
|
||||
{
|
||||
name: "ends a content-filtered step with refusal",
|
||||
events: live(stepEnded(root, "msg_1", { finish: "content-filter" }), succeeded(root)),
|
||||
expected: { response: { stopReason: "refusal" } },
|
||||
},
|
||||
{
|
||||
name: "ends a content-filter failure with refusal and no error",
|
||||
events: live(failed(root, { type: "provider.content-filter", message: "blocked" })),
|
||||
expected: { response: { stopReason: "refusal" }, failure: undefined },
|
||||
},
|
||||
{
|
||||
name: "ends a server-side interruption with cancelled",
|
||||
events: live(durableEvent("session.execution.interrupted", { sessionID: root, reason: "shutdown" })),
|
||||
expected: { terminal: "interrupted", response: { stopReason: "cancelled" } },
|
||||
},
|
||||
{
|
||||
name: "ends an aborted execution with cancelled and no error",
|
||||
events: live(failed(root, { type: "aborted", message: "interrupted" })),
|
||||
expected: { response: { stopReason: "cancelled" }, failure: undefined },
|
||||
},
|
||||
{
|
||||
name: "fails an execution auth failure as auth required",
|
||||
events: live(failed(root, { type: "provider.auth", message: "missing key" })),
|
||||
expected: { failure: "ACPAuthRequiredError" },
|
||||
},
|
||||
{
|
||||
name: "fails a step auth failure as auth required even when the execution succeeds",
|
||||
events: live(
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID: root,
|
||||
assistantMessageID: "msg_1",
|
||||
error: { type: "provider.auth", message: "expired" },
|
||||
}),
|
||||
succeeded(root),
|
||||
),
|
||||
expected: { terminal: "succeeded", failure: "ACPAuthRequiredError" },
|
||||
},
|
||||
{
|
||||
name: "fails other execution failures as service failures",
|
||||
events: live(failed(root, { type: "provider.rate-limit", message: "slow down" })),
|
||||
expected: { failure: "ACPServiceFailureError" },
|
||||
},
|
||||
{
|
||||
name: "sums usage across steps and reports thought and cache tokens",
|
||||
events: live(
|
||||
stepEnded(root, "msg_1", { finish: "tool-calls", tokens: usage(100, 10, 0, 0, 50) }),
|
||||
stepEnded(root, "msg_2", { tokens: usage(20, 5, 3, 150) }),
|
||||
succeeded(root),
|
||||
),
|
||||
expected: {
|
||||
response: {
|
||||
usage: {
|
||||
inputTokens: 120,
|
||||
outputTokens: 15,
|
||||
thoughtTokens: 3,
|
||||
cachedReadTokens: 150,
|
||||
cachedWriteTokens: 50,
|
||||
totalTokens: 338,
|
||||
},
|
||||
},
|
||||
context: usage(20, 5, 3, 150),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "counts a failed step's tokens and clears its error when the next step starts",
|
||||
events: live(
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID: root,
|
||||
assistantMessageID: "msg_1",
|
||||
error: { type: "provider.stream", message: "stream interrupted" },
|
||||
cost: 0,
|
||||
tokens: { ...tokens(), input: 40, output: 4 },
|
||||
tokens: usage(40, 4),
|
||||
}),
|
||||
durableEvent("session.step.started", {
|
||||
sessionID: root,
|
||||
@@ -87,64 +197,413 @@ describe("acp turn translation", () => {
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
started: 0,
|
||||
}),
|
||||
stepEnded(root, "msg_2", { finish: "length", tokens: { ...tokens(), input: 20, output: 7, reasoning: 2 } }),
|
||||
)
|
||||
|
||||
expect(result.state.usage).toEqual({
|
||||
turn: { input: 60, output: 11, reasoning: 2, cache: { read: 0, write: 0 } },
|
||||
last: { input: 20, output: 7, reasoning: 2, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
expect(ACPTranslate.failure(result.state)).toBeUndefined()
|
||||
expect(ACPTranslate.response(result.state, root, "succeeded")).toEqual({
|
||||
stopReason: "max_tokens",
|
||||
usage: { inputTokens: 60, outputTokens: 11, thoughtTokens: 2, totalTokens: 73 },
|
||||
_meta: {},
|
||||
})
|
||||
})
|
||||
|
||||
test("marks a compaction that fails before starting, and drops an end with no start", () => {
|
||||
const result = started(
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID: root,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
stepEnded(root, "msg_2", { tokens: usage(20, 7) }),
|
||||
succeeded(root),
|
||||
),
|
||||
expected: {
|
||||
response: { stopReason: "end_turn", usage: { inputTokens: 60, outputTokens: 11, totalTokens: 71 } },
|
||||
failure: undefined,
|
||||
context: usage(20, 7),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "excludes child session steps from the turn usage",
|
||||
events: live(
|
||||
childCreated("ses_child", root, "Explore"),
|
||||
stepEnded("ses_child", "msg_child", { tokens: usage(500, 50) }),
|
||||
succeeded("ses_child"),
|
||||
stepEnded(root, "msg_root", { tokens: usage(20, 7) }),
|
||||
succeeded(root),
|
||||
),
|
||||
expected: { response: { usage: { inputTokens: 20, outputTokens: 7, totalTokens: 27 } }, context: usage(20, 7) },
|
||||
},
|
||||
{
|
||||
name: "streams a tool call from pending through progress to completion",
|
||||
events: live(
|
||||
toolStarted(root, "call_ok", "shell"),
|
||||
toolCalled(root, "call_ok", { command: "printf done", workdir: "sub" }),
|
||||
toolProgress(root, "call_ok", { phase: 1 }),
|
||||
toolSucceeded(root, "call_ok", { exit: 0 }, "done"),
|
||||
),
|
||||
expected: {
|
||||
updates: [
|
||||
{ sessionUpdate: "tool_call", toolCallId: "call_ok", status: "pending", kind: "execute" },
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "in_progress",
|
||||
title: "printf done",
|
||||
locations: [{ path: path.resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
},
|
||||
{ sessionUpdate: "tool_call_update", status: "in_progress" },
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fails a tool call with its partial content and error",
|
||||
events: live(
|
||||
toolStarted(root, "call_fail", "read"),
|
||||
toolCalled(root, "call_fail", { path: "/workspace/missing.ts" }),
|
||||
toolFailed(root, "call_fail", {
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
}),
|
||||
durableEvent("session.compaction.ended", { sessionID: root, reason: "auto", text: "summary", recent: "" }),
|
||||
)
|
||||
|
||||
expect(updates(result.outputs).map((update) => update._meta?.["opencode/compaction"])).toEqual([
|
||||
{
|
||||
status: "failed",
|
||||
messageId: "msg_2",
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
},
|
||||
])
|
||||
expect(result.state.compactions.size).toBe(0)
|
||||
})
|
||||
|
||||
test("projects a nested child's updates with its depth and its own parent", () => {
|
||||
const result = started(
|
||||
),
|
||||
expected: {
|
||||
updates: [
|
||||
{ status: "pending" },
|
||||
{ status: "in_progress" },
|
||||
{
|
||||
status: "failed",
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/missing.ts" }],
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "opening" } },
|
||||
{ type: "content", content: { type: "text", text: "not found" } },
|
||||
],
|
||||
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locates the file of an edit tool",
|
||||
events: live(
|
||||
toolStarted(root, "call_edit", "edit"),
|
||||
toolCalled(root, "call_edit", { path: "/workspace/a.ts", oldString: "a", newString: "b" }),
|
||||
toolSucceeded(root, "call_edit", {}, "edited"),
|
||||
),
|
||||
expected: {
|
||||
updates: [{}, { locations: [{ path: "/workspace/a.ts" }] }, { locations: [{ path: "/workspace/a.ts" }] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locates the file of a write tool",
|
||||
events: live(
|
||||
toolStarted(root, "call_write", "write"),
|
||||
toolCalled(root, "call_write", { path: "/workspace/b.ts", content: "b" }),
|
||||
toolSucceeded(root, "call_write", {}, "written"),
|
||||
),
|
||||
expected: {
|
||||
updates: [{}, { locations: [{ path: "/workspace/b.ts" }] }, { locations: [{ path: "/workspace/b.ts" }] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locates the files of a patch tool",
|
||||
events: live(
|
||||
toolStarted(root, "call_patch", "patch"),
|
||||
toolCalled(root, "call_patch", {
|
||||
patchText: "*** Begin Patch\n*** Update File: /workspace/c.ts\n@@\n-one\n+two\n*** End Patch",
|
||||
}),
|
||||
toolSucceeded(root, "call_patch", {}, "patched"),
|
||||
),
|
||||
expected: {
|
||||
updates: [{}, { locations: [{ path: "/workspace/c.ts" }] }, { locations: [{ path: "/workspace/c.ts" }] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "projects a child's tool call onto the parent with its ID and title prefixed",
|
||||
events: live(childCreated("ses_child", root, "Explore"), toolStarted("ses_child", "call_1", "read")),
|
||||
expected: {
|
||||
updates: [
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "ses_child:call_1",
|
||||
title: "Explore: read",
|
||||
_meta: childMeta("ses_child", root, 1, "Explore"),
|
||||
},
|
||||
],
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "projects a nested child with its depth and its own parent, and ignores unrelated sessions",
|
||||
events: live(
|
||||
childCreated("ses_child", root, "Explore"),
|
||||
childCreated("ses_grandchild", "ses_child", "Deeper"),
|
||||
childCreated("ses_stranger", "ses_unknown", "Unrelated"),
|
||||
toolStarted("ses_grandchild", "call_1", "read"),
|
||||
textDelta("ses_stranger", "msg_stranger", "ignored"),
|
||||
)
|
||||
|
||||
expect(updates(result.outputs)).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "ses_grandchild:call_1",
|
||||
title: "Deeper: read",
|
||||
_meta: { "opencode/child-session": { id: "ses_grandchild", parentID: "ses_child", depth: 2, title: "Deeper" } },
|
||||
),
|
||||
expected: {
|
||||
updates: [
|
||||
{
|
||||
toolCallId: "ses_grandchild:call_1",
|
||||
title: "Deeper: read",
|
||||
_meta: childMeta("ses_grandchild", "ses_child", 2, "Deeper"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "routes child work to the extension when the client supports it",
|
||||
ctx: { childUpdates: true },
|
||||
events: live(
|
||||
childCreated("ses_child", root, "Explore"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
textDelta("ses_child", "msg_child", "nested"),
|
||||
failed("ses_child", { type: "tool.error", message: "boom" }),
|
||||
),
|
||||
expected: {
|
||||
updates: [],
|
||||
terminal: undefined,
|
||||
children: [
|
||||
{ childSessionId: "ses_child", parentSessionId: root, depth: 1, type: "status", status: "created" },
|
||||
{ childSessionId: "ses_child", type: "status", status: "running" },
|
||||
{ childSessionId: "ses_child", type: "update", update: { sessionUpdate: "agent_message_chunk" } },
|
||||
{ childSessionId: "ses_child", type: "status", status: "failed", error: { type: "tool.error" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fails the tools a cancelled turn left open, including a child's",
|
||||
events: live(
|
||||
childCreated("ses_child", root, "Explore"),
|
||||
toolStarted(root, "call_root", "shell"),
|
||||
toolCalled(root, "call_root", { command: "sleep 60" }),
|
||||
toolProgress(root, "call_root", { pid: 1 }),
|
||||
toolStarted("ses_child", "call_child", "read"),
|
||||
),
|
||||
expected: {
|
||||
abandoned: [
|
||||
{
|
||||
toolCallId: "call_root",
|
||||
status: "failed",
|
||||
rawInput: { command: "sleep 60" },
|
||||
rawOutput: { metadata: { pid: 1 }, error: "Cancelled" },
|
||||
},
|
||||
{ toolCallId: "ses_child:call_child", status: "failed", title: "Explore: read" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cancels the compaction a cancelled turn left open",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.started()),
|
||||
expected: {
|
||||
abandoned: [{ sessionUpdate: "compaction_update", compactionId: "msg_compact", status: "cancelled" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "streams a compaction's summary and completes it with the full summary",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.started(), compaction.delta("Sum"), compaction.delta("mary"), compaction.ended("Summary")),
|
||||
expected: {
|
||||
updates: [
|
||||
{ sessionUpdate: "compaction_update", compactionId: "msg_compact", status: "in_progress" },
|
||||
{ sessionUpdate: "compaction_summary_chunk", compactionId: "msg_compact", content: { text: "Sum" } },
|
||||
{ sessionUpdate: "compaction_summary_chunk", compactionId: "msg_compact", content: { text: "mary" } },
|
||||
{ status: "completed", summary: [{ type: "text", text: "Summary" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clears the summary of a compaction that completes without a readable one",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.started(), compaction.ended("")),
|
||||
expected: { updates: [{ status: "in_progress" }, { status: "completed", summary: null }] },
|
||||
},
|
||||
{
|
||||
name: "fails a compaction with its error and clears the partial summary",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.started(), compaction.delta("partial"), compaction.failed(providerError)),
|
||||
expected: {
|
||||
updates: [
|
||||
{ status: "in_progress" },
|
||||
{ sessionUpdate: "compaction_summary_chunk" },
|
||||
{ status: "failed", summary: null, error: "summary request failed" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps core's own compaction failure messages",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.started(), compaction.failed({ type: "compaction.failed", message: "Output limit" })),
|
||||
expected: { updates: [{ status: "in_progress" }, { status: "failed", error: "Output limit" }] },
|
||||
},
|
||||
{
|
||||
name: "reports a compaction defect as a generic failure",
|
||||
ctx: { compaction: true },
|
||||
events: live(
|
||||
compaction.started(),
|
||||
compaction.failed({ type: "compaction.failed", message: "Error: locked\n at run (compaction.ts:1:1)" }),
|
||||
),
|
||||
expected: { updates: [{ status: "in_progress" }, { status: "failed", error: "Compaction failed" }] },
|
||||
},
|
||||
{
|
||||
name: "reports an interrupted compaction as cancelled",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.started(), compaction.failed({ type: "compaction.interrupted", message: "interrupted" })),
|
||||
expected: { updates: [{ status: "in_progress" }, { status: "cancelled", summary: null }] },
|
||||
},
|
||||
{
|
||||
name: "reports an aborted compaction as cancelled",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.started(), compaction.failed({ type: "aborted", message: "Compaction cancelled" })),
|
||||
expected: { updates: [{ status: "in_progress" }, { status: "cancelled", summary: null }] },
|
||||
},
|
||||
{
|
||||
name: "opens a compaction that fails before it starts before settling it",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.failed({ type: "compaction.unavailable", message: "Nothing to compact yet" }, "auto")),
|
||||
expected: {
|
||||
updates: [
|
||||
{ compactionId: "msg_2", status: "in_progress" },
|
||||
{ compactionId: "msg_2", status: "failed", error: "Nothing to compact yet" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drops summary chunks outside an open compaction",
|
||||
ctx: { compaction: true },
|
||||
events: live(
|
||||
compaction.delta("early"),
|
||||
compaction.started(),
|
||||
compaction.ended("Summary"),
|
||||
compaction.delta("late"),
|
||||
),
|
||||
expected: { updates: [{ status: "in_progress" }, { status: "completed" }] },
|
||||
},
|
||||
{
|
||||
name: "drops a compaction end without a start",
|
||||
ctx: { compaction: true },
|
||||
events: live(compaction.ended("Summary")),
|
||||
expected: { updates: [] },
|
||||
},
|
||||
{
|
||||
name: "marks a compaction without forwarding its summary when the client lacks the capability",
|
||||
events: live(compaction.started(), compaction.delta("Summary"), compaction.ended("Summary")),
|
||||
expected: { updates: [marker({ status: "started" }), marker({ status: "completed" })] },
|
||||
},
|
||||
{
|
||||
name: "marks a failed compaction with the full error",
|
||||
events: live(compaction.started(), compaction.failed(providerError)),
|
||||
expected: { updates: [marker({ status: "started" }), marker({ status: "failed", error: providerError })] },
|
||||
},
|
||||
{
|
||||
name: "marks a compaction that fails before it starts without opening it",
|
||||
events: live(compaction.failed(providerError, "auto")),
|
||||
expected: {
|
||||
updates: [
|
||||
{
|
||||
_meta: {
|
||||
"opencode/compaction": { status: "failed", messageId: "msg_2", reason: "auto", error: providerError },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps a child's compaction projected onto the parent as a marker",
|
||||
ctx: { compaction: true },
|
||||
events: live(
|
||||
childCreated("ses_child", root, "Explore"),
|
||||
compaction.started("ses_child"),
|
||||
compaction.ended("Summary", "ses_child"),
|
||||
),
|
||||
expected: {
|
||||
updates: [
|
||||
{ _meta: { "opencode/compaction": { status: "started" }, ...childMeta("ses_child", root, 1, "Explore") } },
|
||||
{ _meta: { "opencode/compaction": { status: "completed" }, ...childMeta("ses_child", root, 1, "Explore") } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sends a child's compaction as standard updates through the extension",
|
||||
ctx: { compaction: true, childUpdates: true },
|
||||
events: live(
|
||||
childCreated("ses_child", root, "Explore"),
|
||||
compaction.started("ses_child"),
|
||||
compaction.ended("Summary", "ses_child"),
|
||||
),
|
||||
expected: {
|
||||
updates: [],
|
||||
children: [
|
||||
{ status: "created" },
|
||||
{ type: "update", update: { sessionUpdate: "compaction_update", status: "in_progress" } },
|
||||
{ type: "update", update: { sessionUpdate: "compaction_update", status: "completed" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replays a completed compaction as one terminal update",
|
||||
ctx: { compaction: true },
|
||||
messages: [compactionMessage({ status: "completed", summary: "Summary", recent: "" })],
|
||||
expected: {
|
||||
updates: [{ compactionId: "msg_compaction", status: "completed", summary: [{ type: "text", text: "Summary" }] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replays a compaction without a summary as cleared",
|
||||
ctx: { compaction: true },
|
||||
messages: [compactionMessage({ status: "completed", summary: "", recent: "" })],
|
||||
expected: { updates: [{ status: "completed", summary: null }] },
|
||||
},
|
||||
{
|
||||
name: "replays a failed compaction with its error",
|
||||
ctx: { compaction: true },
|
||||
messages: [compactionMessage({ status: "failed", error: providerError })],
|
||||
expected: { updates: [{ status: "failed", summary: null, error: "summary request failed" }] },
|
||||
},
|
||||
{
|
||||
name: "replays a cancelled compaction as cancelled",
|
||||
ctx: { compaction: true },
|
||||
messages: [compactionMessage({ status: "failed", error: { type: "aborted", message: "Compaction cancelled" } })],
|
||||
expected: { updates: [{ status: "cancelled", summary: null }] },
|
||||
},
|
||||
{
|
||||
name: "skips a running compaction on replay",
|
||||
ctx: { compaction: true },
|
||||
messages: [compactionMessage({ status: "running", summary: "", recent: "" })],
|
||||
expected: { updates: [] },
|
||||
},
|
||||
{
|
||||
name: "replays a compaction as a marker when the client lacks the capability",
|
||||
messages: [compactionMessage({ status: "completed", summary: "Summary", recent: "" })],
|
||||
expected: {
|
||||
updates: [
|
||||
{
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: { "opencode/compaction": { status: "completed", messageId: "msg_compaction", reason: "auto" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replays reasoning with the same message IDs as live reasoning ordinals",
|
||||
messages: [
|
||||
assistantMessage("msg_a", {
|
||||
content: [
|
||||
{ type: "reasoning", text: "think" },
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "more" },
|
||||
],
|
||||
}),
|
||||
])
|
||||
expect([...result.state.openChildren]).toEqual(["ses_child", "ses_grandchild"])
|
||||
],
|
||||
expected: {
|
||||
updates: [
|
||||
{ sessionUpdate: "agent_thought_chunk", messageId: "msg_a:reasoning:0" },
|
||||
{ sessionUpdate: "agent_message_chunk", messageId: "msg_a" },
|
||||
{ sessionUpdate: "agent_thought_chunk", messageId: "msg_a:reasoning:1" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
describe("acp turn translation", () => {
|
||||
test.each(rows)("$name", (row) => {
|
||||
expect(translate(row)).toMatchObject(row.expected)
|
||||
})
|
||||
|
||||
test("ends a background consumer when its last open child settles, without session updates", () => {
|
||||
const turn = started(childCreated("ses_a", root, "A"), childCreated("ses_b", root, "B"), succeeded(root))
|
||||
const turn = run(live(childCreated("ses_a", root, "A"), childCreated("ses_b", root, "B"), succeeded(root)))
|
||||
const background = { ...ctx, mode: "background" as const }
|
||||
const first = run(
|
||||
[textDelta(root, "msg_root", "ignored"), toolStarted("ses_a", "call_1", "read"), succeeded("ses_a")],
|
||||
@@ -159,30 +618,52 @@ describe("acp turn translation", () => {
|
||||
expect(last.state.children.has("ses_later")).toBe(false)
|
||||
expect(last.terminal).toBe("interrupted")
|
||||
})
|
||||
|
||||
test("fails the tools a cancelled turn left open, including a child's", () => {
|
||||
const result = started(
|
||||
childCreated("ses_child", root, "Explore"),
|
||||
toolStarted(root, "call_root", "shell"),
|
||||
toolCalled(root, "call_root", { command: "sleep 60" }),
|
||||
toolProgress(root, "call_root", { pid: 1 }),
|
||||
toolStarted("ses_child", "call_child", "read"),
|
||||
failed("ses_child", { type: "aborted", message: "interrupted" }),
|
||||
)
|
||||
|
||||
const abandoned = ACPTranslate.abandon(result.state, ctx)
|
||||
|
||||
expect(abandoned.state.tools.size).toBe(0)
|
||||
expect(updates(abandoned.outputs)).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call_root",
|
||||
status: "failed",
|
||||
rawInput: expect.objectContaining({ command: "sleep 60" }),
|
||||
rawOutput: { metadata: { pid: 1 }, error: "Cancelled" },
|
||||
}),
|
||||
expect.objectContaining({ toolCallId: "ses_child:call_child", status: "failed", title: "Explore: read" }),
|
||||
])
|
||||
expect(ACPTranslate.abandon(abandoned.state, ctx).outputs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function translate(row: Row) {
|
||||
const context = { ...ctx, ...row.ctx }
|
||||
if (row.messages) {
|
||||
const capabilities = {
|
||||
childSessionUpdates: context.childUpdates,
|
||||
formElicitation: false,
|
||||
compaction: context.compaction,
|
||||
}
|
||||
return {
|
||||
updates: row.messages.flatMap((message) => [
|
||||
...ACPReplay.updates(decodeMessage(message), context.cwd, capabilities),
|
||||
]),
|
||||
}
|
||||
}
|
||||
const result = run(row.events ?? [], context)
|
||||
return {
|
||||
updates: updates(result.outputs),
|
||||
children: result.outputs.flatMap((output) => (output._tag === "ChildUpdate" ? [output.update] : [])),
|
||||
terminal: result.terminal,
|
||||
response: result.terminal ? ACPTranslate.response(result.state, root, result.terminal) : undefined,
|
||||
failure: ACPTranslate.failure(result.state)?._tag,
|
||||
context: result.state.usage?.last,
|
||||
abandoned: updates(ACPTranslate.abandon(result.state, context).outputs),
|
||||
}
|
||||
}
|
||||
|
||||
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx, state = ACPTranslate.initial) {
|
||||
return events.reduce<{
|
||||
state: ACPTranslate.TurnState
|
||||
outputs: ACPTranslate.Output[]
|
||||
terminal?: ACPTranslate.Terminal
|
||||
}>(
|
||||
(acc, event, index) => {
|
||||
const next = ACPTranslate.step(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
|
||||
return {
|
||||
state: next.state,
|
||||
outputs: [...acc.outputs, ...next.outputs],
|
||||
...(next.terminal ? { terminal: next.terminal } : {}),
|
||||
}
|
||||
},
|
||||
{ state, outputs: [] },
|
||||
)
|
||||
}
|
||||
|
||||
function updates(outputs: ReadonlyArray<ACPTranslate.Output>) {
|
||||
return outputs.flatMap((output) => (output._tag === "SessionUpdate" ? [output.update] : []))
|
||||
}
|
||||
+105
-394
@@ -1,347 +1,129 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { resolve } from "node:path"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
permissionAsked,
|
||||
reasoningDelta,
|
||||
startSession,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolCalled,
|
||||
toolFailed,
|
||||
toolProgress,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
turn,
|
||||
type ChildUpdate,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const grandchild = {
|
||||
"opencode/child-session": { id: "ses_grandchild", parentID: "ses_child", depth: 2, title: "Deeper" },
|
||||
}
|
||||
|
||||
describe("acp turn events over the wire", () => {
|
||||
test("isolates events from other sessions and other inputs", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
textDelta(sessionID, "msg_before", "before admission"),
|
||||
delivered("ses_other", id),
|
||||
delivered(sessionID, "msg_other"),
|
||||
textDelta(sessionID, "msg_wrong_input", "wrong input"),
|
||||
delivered(sessionID, id),
|
||||
textDelta("ses_other", "msg_other", "other session"),
|
||||
textDelta(sessionID, "msg_accepted", "accepted"),
|
||||
stepEnded(sessionID, "msg_accepted"),
|
||||
succeeded("ses_other"),
|
||||
succeeded(sessionID),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_accepted",
|
||||
content: { type: "text", text: "accepted" },
|
||||
test.each([
|
||||
{
|
||||
childSessionUpdates: false,
|
||||
parent: [
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "ses_grandchild:call_read",
|
||||
title: "Deeper: read",
|
||||
_meta: grandchild,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("streams ordered reasoning and text chunks before admission returns", async () => {
|
||||
const releaseAdmission = Promise.withResolvers<void>()
|
||||
await using acp = await startSession({ onPrompt: () => releaseAdmission.promise })
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
const settled = { value: false }
|
||||
void prompt.finally(() => {
|
||||
settled.value = true
|
||||
})
|
||||
const submitted = await acp.until(() => acp.server.prompts[0], "prompt submission")
|
||||
const sessionID = acp.sessionId
|
||||
acp.server.send(
|
||||
...turn(
|
||||
sessionID,
|
||||
submitted.id,
|
||||
reasoningDelta(sessionID, "msg_order", "think-1"),
|
||||
reasoningDelta(sessionID, "msg_order", " continued"),
|
||||
textDelta(sessionID, "msg_order", "answer", 1),
|
||||
reasoningDelta(sessionID, "msg_order", "think-2", 1),
|
||||
stepEnded(sessionID, "msg_order"),
|
||||
),
|
||||
)
|
||||
await acp.until(() => chunks(acp.updates).length === 4, "streamed chunks")
|
||||
expect(settled.value).toBe(false)
|
||||
|
||||
releaseAdmission.resolve()
|
||||
expect((await prompt).stopReason).toBe("end_turn")
|
||||
expect(chunks(acp.updates)).toEqual([
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
|
||||
["agent_message_chunk", "msg_order", "answer"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
|
||||
])
|
||||
})
|
||||
|
||||
test("replays reasoning parts with the same message IDs as live reasoning ordinals", async () => {
|
||||
await using acp = await startSession()
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
assistantMessage("msg_order", {
|
||||
time: { created: 1 },
|
||||
content: [
|
||||
{ type: "reasoning", text: "think-1 continued" },
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "think-2" },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] })
|
||||
|
||||
expect(chunks(acp.updates)).toEqual([
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", "think-1 continued"],
|
||||
["agent_message_chunk", "msg_order", "answer"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
|
||||
])
|
||||
})
|
||||
|
||||
test("projects foreground child session updates onto the parent turn without the child capability", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
toolStarted("ses_child", "call_read", "read"),
|
||||
toolCalled("ses_child", "call_read", { path: "/workspace/src/index.ts" }),
|
||||
toolSucceeded("ses_child", "call_read", {}, "source"),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const updates = turnUpdates(acp.updates)
|
||||
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate, toolCallID(item)])).toEqual([
|
||||
[acp.sessionId, "tool_call", "ses_child:call_read"],
|
||||
[acp.sessionId, "tool_call_update", "ses_child:call_read"],
|
||||
[acp.sessionId, "tool_call_update", "ses_child:call_read"],
|
||||
])
|
||||
expect(updates[0]?.update).toMatchObject({
|
||||
title: "Explore code: read",
|
||||
_meta: {
|
||||
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore code" },
|
||||
},
|
||||
})
|
||||
expect(acp.childUpdates).toEqual([])
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("routes foreground and nested child updates to the extension when the client supports it", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
childCreated("ses_grandchild", "ses_child", "Deeper"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_grandchild" }),
|
||||
textDelta("ses_grandchild", "msg_grandchild", "nested"),
|
||||
failed("ses_grandchild", { type: "tool.error", message: "boom" }),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([])
|
||||
expect(acp.childUpdates).toEqual([
|
||||
expect.objectContaining({ childSessionId: "ses_child", depth: 1, type: "status", status: "created" }),
|
||||
expect.objectContaining({
|
||||
childSessionId: "ses_grandchild",
|
||||
parentSessionId: "ses_child",
|
||||
rootSessionId: acp.sessionId,
|
||||
depth: 2,
|
||||
title: "Deeper",
|
||||
type: "status",
|
||||
status: "created",
|
||||
}),
|
||||
expect.objectContaining({ childSessionId: "ses_grandchild", type: "status", status: "running" }),
|
||||
expect.objectContaining({
|
||||
childSessionId: "ses_grandchild",
|
||||
type: "update",
|
||||
update: expect.objectContaining({ sessionUpdate: "agent_message_chunk", messageId: "msg_grandchild" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
childSessionId: "ses_grandchild",
|
||||
type: "status",
|
||||
status: "failed",
|
||||
error: { type: "tool.error", message: "boom" },
|
||||
}),
|
||||
expect.objectContaining({ childSessionId: "ses_child", type: "status", status: "completed" }),
|
||||
])
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("continues child extension updates after the parent turn ends", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, childCreated("ses_background", sessionID, "Background research")),
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
|
||||
acp.server.send(
|
||||
childCreated("ses_future", acp.sessionId, "Later turn child"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_future" }),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_background" }),
|
||||
toolStarted("ses_background", "call_shell", "shell"),
|
||||
toolCalled("ses_background", "call_shell", { command: "pwd" }),
|
||||
toolSucceeded("ses_background", "call_shell", { exit: 0 }, "/workspace"),
|
||||
succeeded("ses_background"),
|
||||
)
|
||||
await acp.until(
|
||||
() => acp.childUpdates.some((item) => item.type === "status" && item.status === "completed"),
|
||||
"background child completion",
|
||||
)
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([])
|
||||
expect(acp.childUpdates.map(childUpdateKind)).toEqual([
|
||||
"status:created",
|
||||
"status:running",
|
||||
"update:tool_call",
|
||||
"update:tool_call_update",
|
||||
"update:tool_call_update",
|
||||
"status:completed",
|
||||
])
|
||||
expect(acp.childUpdates[2]).toMatchObject({
|
||||
rootSessionId: acp.sessionId,
|
||||
childSessionId: "ses_background",
|
||||
parentSessionId: acp.sessionId,
|
||||
depth: 1,
|
||||
title: "Background research",
|
||||
type: "update",
|
||||
update: { toolCallId: "ses_background:call_shell" },
|
||||
})
|
||||
expect(acp.childUpdates.some((item) => item.childSessionId === "ses_future")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps following open children after a cancelled turn", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
childCreated("ses_background", sessionID, "Background research"),
|
||||
textDelta(sessionID, "msg_root", "working"),
|
||||
{ sessionUpdate: "tool_call_update", toolCallId: "ses_grandchild:call_read", status: "in_progress" },
|
||||
{ sessionUpdate: "tool_call_update", toolCallId: "ses_grandchild:call_read", status: "completed" },
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
permission: () => ({ outcome: { outcome: "selected", optionId: "once" } }),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
acp.server.send(permissionAsked("ses_background", "perm_background"), interrupted("ses_background"))
|
||||
|
||||
await acp.until(
|
||||
() => acp.childUpdates.some((item) => item.type === "status" && item.status === "interrupted"),
|
||||
"background child interruption",
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
|
||||
expect(acp.childUpdates.map(childUpdateKind)).toEqual(["status:created", "status:interrupted"])
|
||||
expect(acp.server.replies).toEqual([
|
||||
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
|
||||
])
|
||||
})
|
||||
|
||||
test("stops following background children once the session closes", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id, text }) =>
|
||||
text === "hello"
|
||||
? turn(sessionID, id, childCreated("ses_background", sessionID, "Background research"))
|
||||
: turn(sessionID, id),
|
||||
})
|
||||
const other = await acp.newSession()
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
await acp.request("session/close", { sessionId: acp.sessionId })
|
||||
acp.server.send(textDelta("ses_background", "msg_late", "after close"))
|
||||
|
||||
expect((await acp.prompt(other.sessionId, "later")).stopReason).toBe("end_turn")
|
||||
expect(acp.childUpdates.map(childUpdateKind)).toEqual(["status:created"])
|
||||
})
|
||||
|
||||
test("streams tool pending, progress, success, and failure updates", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_ok", "shell"),
|
||||
toolCalled(sessionID, "call_ok", { command: "printf done", workdir: "sub" }),
|
||||
toolProgress(sessionID, "call_ok", { phase: 1 }),
|
||||
toolSucceeded(sessionID, "call_ok", { exit: 0 }, "done"),
|
||||
toolStarted(sessionID, "call_fail", "read"),
|
||||
toolCalled(sessionID, "call_fail", { path: "/workspace/missing.ts" }),
|
||||
toolProgress(sessionID, "call_fail", { bytes: 0 }),
|
||||
toolFailed(sessionID, "call_fail", {
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
}),
|
||||
stepEnded(sessionID, "msg_tools"),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const updates = turnUpdates(acp.updates)
|
||||
expect(updates.map((item) => [item.update.sessionUpdate, toolStatus(item), toolCallID(item)])).toEqual([
|
||||
["tool_call", "pending", "call_ok"],
|
||||
["tool_call_update", "in_progress", "call_ok"],
|
||||
["tool_call_update", "in_progress", "call_ok"],
|
||||
["tool_call_update", "completed", "call_ok"],
|
||||
["tool_call", "pending", "call_fail"],
|
||||
["tool_call_update", "in_progress", "call_fail"],
|
||||
["tool_call_update", "in_progress", "call_fail"],
|
||||
["tool_call_update", "failed", "call_fail"],
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
title: "printf done",
|
||||
kind: "execute",
|
||||
locations: [{ path: resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
})
|
||||
expect(updates[2]?.update).not.toHaveProperty("content")
|
||||
expect(updates[3]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[7]?.update).toMatchObject({
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/missing.ts" }],
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "opening" } },
|
||||
{ type: "content", content: { type: "text", text: "not found" } },
|
||||
child: [],
|
||||
},
|
||||
{
|
||||
childSessionUpdates: true,
|
||||
parent: [],
|
||||
child: [
|
||||
{ childSessionId: "ses_child", depth: 1, type: "status", status: "created" },
|
||||
{ childSessionId: "ses_child", type: "status", status: "running" },
|
||||
{
|
||||
childSessionId: "ses_grandchild",
|
||||
parentSessionId: "ses_child",
|
||||
depth: 2,
|
||||
title: "Deeper",
|
||||
status: "created",
|
||||
},
|
||||
{
|
||||
childSessionId: "ses_grandchild",
|
||||
update: { sessionUpdate: "tool_call", toolCallId: "ses_grandchild:call_read" },
|
||||
},
|
||||
{ childSessionId: "ses_grandchild", update: { sessionUpdate: "tool_call_update", status: "in_progress" } },
|
||||
{ childSessionId: "ses_grandchild", update: { sessionUpdate: "tool_call_update", status: "completed" } },
|
||||
{ childSessionId: "ses_grandchild", type: "status", status: "completed" },
|
||||
{ childSessionId: "ses_background", type: "status", status: "created" },
|
||||
{ childSessionId: "ses_child", type: "status", status: "completed" },
|
||||
{ childSessionId: "ses_background", type: "status", status: "running" },
|
||||
],
|
||||
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
})
|
||||
},
|
||||
])(
|
||||
"shows nested and background child work and routes its permissions (child capability: $childSessionUpdates)",
|
||||
async (row) => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { childSessionUpdates: row.childSessionUpdates },
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
childCreated("ses_grandchild", "ses_child", "Deeper"),
|
||||
toolStarted("ses_grandchild", "call_read", "read"),
|
||||
toolCalled("ses_grandchild", "call_read", { path: "/workspace/src/index.ts" }),
|
||||
permissionAsked("ses_grandchild", "perm_child", {
|
||||
action: "read",
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_read" },
|
||||
}),
|
||||
toolSucceeded("ses_grandchild", "call_read", {}, "source"),
|
||||
succeeded("ses_grandchild"),
|
||||
childCreated("ses_background", sessionID, "Research"),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
permission: () => ({ outcome: { outcome: "selected", optionId: "once" } }),
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
acp.server.send(
|
||||
durableEvent("session.execution.started", { sessionID: "ses_background" }),
|
||||
permissionAsked("ses_background", "perm_background", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/notes.md" },
|
||||
}),
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 2, "child permission replies")
|
||||
|
||||
expect(turnUpdates(acp.updates).map((item) => item.update)).toMatchObject(row.parent)
|
||||
expect(acp.childUpdates).toMatchObject(row.child)
|
||||
expect(acp.permissions).toMatchObject([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "ses_grandchild:call_read",
|
||||
title: "Deeper: /workspace/src/index.ts",
|
||||
_meta: grandchild,
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "ses_background:perm_background",
|
||||
title: "Research: /workspace/notes.md",
|
||||
_meta: {
|
||||
"opencode/child-session": { id: "ses_background", parentID: acp.sessionId, depth: 1, title: "Research" },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(acp.server.replies).toEqual([
|
||||
{ sessionID: "ses_grandchild", requestID: "perm_child", decision: "once" },
|
||||
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
test("cancels unsupported session forms so execution can continue", async () => {
|
||||
await using acp = await startSession({
|
||||
@@ -369,51 +151,6 @@ describe("acp turn events over the wire", () => {
|
||||
])
|
||||
expect(acp.elicitations).toEqual([])
|
||||
})
|
||||
|
||||
test("reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)", async () => {
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: /workspace/src/c.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "/workspace/src/a.ts", oldString: "a", newString: "b" }),
|
||||
toolSucceeded(sessionID, "call_edit", {}, "edited"),
|
||||
toolStarted(sessionID, "call_write", "write"),
|
||||
toolCalled(sessionID, "call_write", { path: "/workspace/src/b.ts", content: "b" }),
|
||||
toolSucceeded(sessionID, "call_write", {}, "written"),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
toolSucceeded(sessionID, "call_patch", {}, "patched"),
|
||||
),
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const locations = turnUpdates(acp.updates)
|
||||
.filter((item) => item.update.sessionUpdate === "tool_call_update")
|
||||
.map((item) => [
|
||||
toolCallID(item),
|
||||
toolStatus(item),
|
||||
"locations" in item.update ? item.update.locations : undefined,
|
||||
])
|
||||
expect(locations).toEqual([
|
||||
["call_edit", "in_progress", [{ path: "/workspace/src/a.ts" }]],
|
||||
["call_edit", "completed", [{ path: "/workspace/src/a.ts" }]],
|
||||
["call_write", "in_progress", [{ path: "/workspace/src/b.ts" }]],
|
||||
["call_write", "completed", [{ path: "/workspace/src/b.ts" }]],
|
||||
["call_patch", "in_progress", [{ path: "/workspace/src/c.ts" }]],
|
||||
["call_patch", "completed", [{ path: "/workspace/src/c.ts" }]],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
@@ -421,29 +158,3 @@ function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
)
|
||||
}
|
||||
|
||||
function chunks(updates: readonly SessionNotification[]) {
|
||||
return updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "agent_message_chunk" || item.update.sessionUpdate === "agent_thought_chunk"
|
||||
? [
|
||||
[
|
||||
item.update.sessionUpdate,
|
||||
item.update.messageId,
|
||||
item.update.content.type === "text" ? item.update.content.text : undefined,
|
||||
],
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function childUpdateKind(item: ChildUpdate) {
|
||||
return item.type === "status" ? `status:${item.status}` : `update:${item.update.sessionUpdate}`
|
||||
}
|
||||
|
||||
function toolCallID(item: SessionNotification) {
|
||||
return "toolCallId" in item.update ? item.update.toolCallId : undefined
|
||||
}
|
||||
|
||||
function toolStatus(item: SessionNotification) {
|
||||
return "status" in item.update ? item.update.status : undefined
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type WriteTextFileRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
@@ -49,10 +48,10 @@ type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
|
||||
type EventData<Type extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { type: Type }>["data"]
|
||||
type AssistantMessage = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||
|
||||
export type Events = ReadonlyArray<OpenCodeEvent> | void
|
||||
type Events = ReadonlyArray<OpenCodeEvent> | void
|
||||
type Hook<Input> = (input: Input) => Events | Promise<Events>
|
||||
|
||||
export type ServerRequest = {
|
||||
type ServerRequest = {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly query: Record<string, string>
|
||||
@@ -89,7 +88,7 @@ const FormReplyBody = Schema.Struct({ answer: Form.Answer })
|
||||
const McpBody = Schema.Struct({ config: Schema.Unknown })
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
export type Submission =
|
||||
type Submission =
|
||||
| ({ readonly kind: "prompt"; readonly sessionID: string } & typeof PromptBody.Type)
|
||||
| ({ readonly kind: "command"; readonly sessionID: string } & typeof CommandBody.Type)
|
||||
| ({ readonly kind: "compact"; readonly sessionID: string } & typeof CompactBody.Type)
|
||||
@@ -97,7 +96,7 @@ export type Submission =
|
||||
|
||||
type PromptSubmission = Extract<Submission, { readonly kind: "prompt" }>
|
||||
|
||||
export type Selection =
|
||||
type Selection =
|
||||
| { readonly sessionID: string; readonly model: typeof ModelBody.Type.model }
|
||||
| { readonly sessionID: string; readonly agent: string }
|
||||
|
||||
@@ -123,7 +122,6 @@ const ChildUpdate = Schema.Union([
|
||||
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
|
||||
}),
|
||||
])
|
||||
export type ChildUpdate = typeof ChildUpdate.Type
|
||||
|
||||
export type WireOptions = {
|
||||
readonly fetch?: (request: ServerRequest) => Response | undefined | Promise<Response | undefined>
|
||||
@@ -134,11 +132,6 @@ export type WireOptions = {
|
||||
readonly signal: AbortSignal
|
||||
}>
|
||||
readonly onInterrupt?: Hook<{ readonly sessionID: string }>
|
||||
readonly onPermissionReply?: Hook<{
|
||||
readonly sessionID: string
|
||||
readonly requestID: string
|
||||
readonly decision: string
|
||||
}>
|
||||
readonly onFormCancel?: Hook<{ readonly sessionID: string; readonly formID: string }>
|
||||
readonly onFormReply?: Hook<FormReply>
|
||||
readonly permission?: (
|
||||
@@ -158,25 +151,20 @@ type FormReply = {
|
||||
readonly answer: typeof FormReplyBody.Type.answer
|
||||
}
|
||||
|
||||
type CatalogKind = "model" | "default" | "agent" | "command"
|
||||
|
||||
export type Catalog = {
|
||||
type Catalog = {
|
||||
models: ModelInfo[]
|
||||
// Unset follows the first listed model.
|
||||
defaultModel?: ModelInfo | null
|
||||
agents: AgentInfo[]
|
||||
commands: CommandInfo[]
|
||||
}
|
||||
|
||||
export type InitializeOptions = {
|
||||
readonly writeTextFile?: boolean
|
||||
readonly childSessionUpdates?: boolean
|
||||
readonly terminalAuth?: boolean
|
||||
readonly elicitation?: boolean
|
||||
readonly compaction?: CompactionCapabilities | null
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
const testModel = {
|
||||
id: "test-model",
|
||||
modelID: "test-model",
|
||||
providerID: "test",
|
||||
@@ -204,7 +192,7 @@ export const secondModel = {
|
||||
limit: { context: 200_000, output: 20_000 },
|
||||
} satisfies ModelInfo
|
||||
|
||||
export const buildAgent = {
|
||||
const buildAgent = {
|
||||
id: "build",
|
||||
name: "Build",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
@@ -213,7 +201,7 @@ export const buildAgent = {
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
|
||||
export const planAgent = {
|
||||
const planAgent = {
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Plan first",
|
||||
@@ -404,10 +392,6 @@ export function enqueued(sessionID: string, id: string, type: string, payload: o
|
||||
return Response.json({ data: { id, sessionID, time: { created: 0 }, type, payload, delivery: "steer" } })
|
||||
}
|
||||
|
||||
export function makeClient(baseUrl: string) {
|
||||
return OpenCode.make({ baseUrl }).pipe(Effect.provide(FetchHttpClient.layer))
|
||||
}
|
||||
|
||||
export async function startWire(options: WireOptions = {}) {
|
||||
const waiters = new Set<() => void>()
|
||||
const changed = () => waiters.forEach((check) => check())
|
||||
@@ -416,8 +400,7 @@ export async function startWire(options: WireOptions = {}) {
|
||||
const received: AnyMessage[] = []
|
||||
const updates: SessionNotification[] = []
|
||||
const permissions: RequestPermissionRequest[] = []
|
||||
const writes: WriteTextFileRequest[] = []
|
||||
const childUpdates: ChildUpdate[] = []
|
||||
const childUpdates: Array<typeof ChildUpdate.Type> = []
|
||||
const elicitations: CreateElicitationRequest[] = []
|
||||
// Client handlers record SDK-validated params; responses wait until they have seen every earlier agent message.
|
||||
const counts = { sent: 0, handled: 0 }
|
||||
@@ -432,7 +415,8 @@ export async function startWire(options: WireOptions = {}) {
|
||||
const logs: Array<Pick<Logger.Options<unknown>, "message" | "cause">> = []
|
||||
const agentScope = Scope.makeUnsafe()
|
||||
const agentConnection = await Effect.runPromise(
|
||||
makeClient(server.url).pipe(
|
||||
OpenCode.make({ baseUrl: server.url }).pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.flatMap((client) => ACP.connect(client, ndJsonStream(agentToClient.writable, clientToAgent.readable))),
|
||||
Scope.provide(agentScope),
|
||||
(effect) =>
|
||||
@@ -456,10 +440,6 @@ export async function startWire(options: WireOptions = {}) {
|
||||
handled(elicitations, ctx.params)
|
||||
return options.elicitation?.(ctx.params, ctx.signal) ?? { action: "cancel" }
|
||||
})
|
||||
.onRequest("fs/write_text_file", (ctx) => {
|
||||
handled(writes, ctx.params)
|
||||
return {}
|
||||
})
|
||||
.connect({
|
||||
writable: clientStream.writable,
|
||||
readable: clientStream.readable.pipeThrough(
|
||||
@@ -510,7 +490,6 @@ export async function startWire(options: WireOptions = {}) {
|
||||
request("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
...(capabilities.writeTextFile ? { fs: { writeTextFile: true, readTextFile: false } } : {}),
|
||||
...(capabilities.elicitation ? { elicitation: { form: {} } } : {}),
|
||||
...(capabilities.compaction !== undefined ? { session: { compaction: capabilities.compaction } } : {}),
|
||||
_meta: {
|
||||
@@ -523,13 +502,12 @@ export async function startWire(options: WireOptions = {}) {
|
||||
|
||||
return {
|
||||
server,
|
||||
logs,
|
||||
received,
|
||||
updates,
|
||||
permissions,
|
||||
writes,
|
||||
childUpdates,
|
||||
elicitations,
|
||||
logs,
|
||||
request,
|
||||
until,
|
||||
initialize,
|
||||
@@ -559,7 +537,7 @@ export async function startSession(options: WireOptions & { readonly capabilitie
|
||||
const wire = await startWire(options)
|
||||
await wire.initialize(options.capabilities)
|
||||
const session = await wire.newSession()
|
||||
return Object.assign(wire, { sessionId: session.sessionId, session })
|
||||
return Object.assign(wire, { sessionId: session.sessionId })
|
||||
}
|
||||
|
||||
export async function rpcError(promise: Promise<unknown>) {
|
||||
@@ -582,7 +560,6 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
agents: [buildAgent, planAgent],
|
||||
commands: [reviewCommand],
|
||||
}
|
||||
const catalogReads: Array<{ readonly kind: CatalogKind; readonly directory: string }> = []
|
||||
const requests: ServerRequest[] = []
|
||||
const submissions: Submission[] = []
|
||||
const selections: Selection[] = []
|
||||
@@ -594,7 +571,6 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
const fake = {
|
||||
requests,
|
||||
catalog,
|
||||
catalogReads,
|
||||
sessions: new Map<string, SessionInfo>(),
|
||||
messages: new Map<string, SessionMessageInfo[]>(),
|
||||
submissions,
|
||||
@@ -634,15 +610,6 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
const notFound = (sessionID: string) =>
|
||||
Response.json({ _tag: "SessionNotFoundError", sessionID, message: "session not found" }, { status: 404 })
|
||||
const noContent = () => new Response(null, { status: 204 })
|
||||
// Routes without session-location middleware reject a malformed ID in their path decode; routes with it name the field.
|
||||
const pathDecodeError = {
|
||||
_tag: "InvalidRequestError",
|
||||
message: 'Expected a string starting with "ses"',
|
||||
kind: "Params",
|
||||
}
|
||||
const fieldError = { _tag: "InvalidRequestError", message: "Invalid session ID", field: "sessionID" }
|
||||
const malformed = (sessionID: string, body: object) =>
|
||||
sessionID.startsWith("ses") ? undefined : Response.json(body, { status: 400 })
|
||||
|
||||
// Handlers record facts synchronously before awaiting hooks, so waiters can observe a held request.
|
||||
const observed = (response: Response | Promise<Response>) => {
|
||||
@@ -686,17 +653,10 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
})
|
||||
.finally(changed)
|
||||
}
|
||||
const catalogRoute = (kind: CatalogKind) =>
|
||||
const catalogRoute = (data: () => unknown) =>
|
||||
route((_req, query) => {
|
||||
const directory = query["location[directory]"] ?? "/workspace"
|
||||
fake.catalogReads.push({ kind, directory })
|
||||
const data = {
|
||||
model: catalog.models,
|
||||
default: catalog.defaultModel === undefined ? (catalog.models[0] ?? null) : catalog.defaultModel,
|
||||
agent: catalog.agents,
|
||||
command: catalog.commands,
|
||||
}[kind]
|
||||
return Response.json({ location: { directory, project: { id: "global", directory } }, data })
|
||||
return Response.json({ location: { directory, project: { id: "global", directory } }, data: data() })
|
||||
})
|
||||
const page = <Item>(items: readonly Item[], query: Record<string, string>, limit: number) => {
|
||||
const start = Number(query.cursor ?? 0)
|
||||
@@ -733,10 +693,10 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
)
|
||||
}),
|
||||
},
|
||||
"/api/model": { GET: catalogRoute("model") },
|
||||
"/api/model/default": { GET: catalogRoute("default") },
|
||||
"/api/agent": { GET: catalogRoute("agent") },
|
||||
"/api/command": { GET: catalogRoute("command") },
|
||||
"/api/model": { GET: catalogRoute(() => catalog.models) },
|
||||
"/api/model/default": { GET: catalogRoute(() => catalog.models[0] ?? null) },
|
||||
"/api/agent": { GET: catalogRoute(() => catalog.agents) },
|
||||
"/api/command": { GET: catalogRoute(() => catalog.commands) },
|
||||
"/api/session": {
|
||||
GET: route((_req, query) => {
|
||||
const sessions = [...fake.sessions.values()]
|
||||
@@ -756,16 +716,9 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
},
|
||||
"/api/session/:sessionID": {
|
||||
GET: route((req) => {
|
||||
const invalid = malformed(req.params.sessionID, pathDecodeError)
|
||||
if (invalid) return invalid
|
||||
const session = fake.sessions.get(req.params.sessionID)
|
||||
return session ? Response.json({ data: session }) : notFound(req.params.sessionID)
|
||||
}),
|
||||
DELETE: route((req) => {
|
||||
const invalid = malformed(req.params.sessionID, pathDecodeError)
|
||||
if (invalid) return invalid
|
||||
return fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID)
|
||||
}),
|
||||
PATCH: body(UpdateBody, (req, input) => {
|
||||
const session = fake.sessions.get(req.params.sessionID)
|
||||
if (!session) return notFound(req.params.sessionID)
|
||||
@@ -779,8 +732,6 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
},
|
||||
"/api/session/:sessionID/fork": {
|
||||
POST: route((req) => {
|
||||
const invalid = malformed(req.params.sessionID, fieldError)
|
||||
if (invalid) return invalid
|
||||
const source = fake.sessions.get(req.params.sessionID)
|
||||
if (!source) return notFound(req.params.sessionID)
|
||||
const forked = createSession(source)
|
||||
@@ -803,12 +754,6 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
"/api/session/:sessionID/message": {
|
||||
GET: route((req, query) => Response.json(page(fake.messages.get(req.params.sessionID) ?? [], query, 200))),
|
||||
},
|
||||
"/api/session/:sessionID/message/:messageID": {
|
||||
GET: route((req) => {
|
||||
const message = fake.messages.get(req.params.sessionID)?.find((item) => item.id === req.params.messageID)
|
||||
return message ? Response.json({ data: message }) : new Response(null, { status: 404 })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/prompt": {
|
||||
POST: body(PromptBody, async (req, input) => {
|
||||
const sessionID = req.params.sessionID
|
||||
@@ -846,10 +791,12 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/permission/:requestID/reply": {
|
||||
POST: body(ReplyBody, async (req, input) => {
|
||||
const reply = { sessionID: req.params.sessionID, requestID: req.params.requestID, decision: input.decision }
|
||||
fake.replies.push(reply)
|
||||
await emit(options.onPermissionReply?.(reply))
|
||||
POST: body(ReplyBody, (req, input) => {
|
||||
fake.replies.push({
|
||||
sessionID: req.params.sessionID,
|
||||
requestID: req.params.requestID,
|
||||
decision: input.decision,
|
||||
})
|
||||
return noContent()
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noUnusedLocals": true
|
||||
},
|
||||
"exclude": ["dist", "dist-node"],
|
||||
"references": [{ "path": "../core" }]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowJs": false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["@cloudflare/workers-types", "node"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ const description = [
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.allSettled`.",
|
||||
].join("\n")
|
||||
|
||||
export const create = (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ConfigCompaction } from "@opencode/schema/config/compaction"
|
||||
import { ConfigFormatter } from "@opencode/schema/config/formatter"
|
||||
import { ConfigLSP } from "@opencode/schema/config/lsp"
|
||||
import { ConfigMedia } from "@opencode/schema/config/media"
|
||||
import { ConfigModel } from "@opencode/schema/config/model"
|
||||
import { ConfigMCP } from "@opencode/schema/config/mcp"
|
||||
import { ConfigPlugin } from "@opencode/schema/config/plugin"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
@@ -18,13 +19,11 @@ import { ConfigReference } from "@opencode/schema/config/reference"
|
||||
import { ConfigExperimental } from "@opencode/schema/config/experimental"
|
||||
import { Permission } from "@opencode/schema/permission"
|
||||
import { ConfigAgentV1 } from "../v1/config/agent.js"
|
||||
import { ConfigAttachmentV1 } from "../v1/config/attachment.js"
|
||||
import { ConfigCommandV1 } from "../v1/config/command.js"
|
||||
import { ConfigMCPV1 } from "../v1/config/mcp.js"
|
||||
import { ConfigPermissionV1 } from "../v1/config/permission.js"
|
||||
import { ConfigPluginV1 } from "../v1/config/plugin.js"
|
||||
import { ConfigProviderV1 } from "../v1/config/provider.js"
|
||||
import { ConfigV1 } from "../v1/config/config.js"
|
||||
import { ConfigMigrateV1 } from "../v1/config/migrate.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
|
||||
@@ -53,6 +52,7 @@ const unsupportedExperimental = [
|
||||
] as const
|
||||
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
|
||||
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
|
||||
const LegacyAutoupdate = Schema.Literals([true, false, "notify"])
|
||||
|
||||
export function normalize(input: unknown): Result {
|
||||
if (!isRecord(input))
|
||||
@@ -71,7 +71,7 @@ export function normalize(input: unknown): Result {
|
||||
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
|
||||
: undefined
|
||||
const legacyUpdate = own(input, "autoupdate")
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
? decodeValue(LegacyAutoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
@@ -82,15 +82,18 @@ export function normalize(input: unknown): Result {
|
||||
: undefined
|
||||
: undefined
|
||||
const legacyMedia = own(input, "attachment")
|
||||
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
|
||||
? decodeEncoded(ConfigMedia.Info, input.attachment, ["attachment"], diagnostics)
|
||||
: undefined
|
||||
if (legacyMedia !== undefined) {
|
||||
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
|
||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||
}
|
||||
if (legacyMedia !== undefined) encoded.media = legacyMedia
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
const migratedUpdate =
|
||||
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
legacyUpdate === false
|
||||
? "disable"
|
||||
: legacyUpdate === "notify"
|
||||
? "notify"
|
||||
: legacyUpdate === true
|
||||
? "auto"
|
||||
: undefined
|
||||
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
|
||||
if (update !== undefined) encoded.update = update
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
@@ -114,10 +117,9 @@ export function normalize(input: unknown): Result {
|
||||
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
|
||||
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
||||
const migratedCommands = mapValues(legacyCommands, (value) => {
|
||||
const migrated = ConfigMigrateV1.commands({ value })?.value
|
||||
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
||||
})
|
||||
const migratedCommands = mapValues(legacyCommands, (value) =>
|
||||
canonical(ConfigCommand.Info, ConfigMigrateV1.migrateCommand(value)),
|
||||
)
|
||||
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
|
||||
mergeMap(
|
||||
encoded,
|
||||
@@ -135,9 +137,7 @@ export function normalize(input: unknown): Result {
|
||||
const legacySmallModel = own(input, "small_model")
|
||||
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
||||
: undefined
|
||||
const migratedSmallModel = legacySmallModel
|
||||
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
|
||||
: undefined
|
||||
const migratedSmallModel = ConfigMigrateV1.modelSelection(legacySmallModel)
|
||||
if (legacySmallModel && !migratedSmallModel)
|
||||
diagnostics.push({
|
||||
kind: "unsupported",
|
||||
@@ -197,10 +197,14 @@ export function normalize(input: unknown): Result {
|
||||
normalizeFormatter(input, encoded, diagnostics)
|
||||
normalizeLsp(input, encoded, diagnostics)
|
||||
|
||||
const model = own(input, "model")
|
||||
? decodeValue(ConfigModel.Selection, input.model, ["model"], diagnostics)
|
||||
: undefined
|
||||
if (model) encoded.model = { ...model, providerID: ConfigMigrateV1.providerID(model.providerID) }
|
||||
|
||||
const nativeAtomic = {
|
||||
$schema: Info.fields.$schema,
|
||||
shell: Info.fields.shell,
|
||||
model: Info.fields.model,
|
||||
default_agent: Info.fields.default_agent,
|
||||
share: Info.fields.share,
|
||||
enterprise: Info.fields.enterprise,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { create } from "@opencode/schema/identifier"
|
||||
|
||||
const prefixes = {
|
||||
job: "job",
|
||||
event: "evt",
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
question: "que",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
} as const
|
||||
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, "ascending", given)
|
||||
}
|
||||
|
||||
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, "descending", given)
|
||||
}
|
||||
|
||||
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
|
||||
if (!given) {
|
||||
return createID(prefixes[prefix], direction)
|
||||
}
|
||||
|
||||
if (!given.startsWith(prefixes[prefix])) {
|
||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||
}
|
||||
return given
|
||||
}
|
||||
|
||||
function createID(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
|
||||
return prefix + "_" + create(direction === "descending", timestamp)
|
||||
}
|
||||
|
||||
export { createID as create }
|
||||
|
||||
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
|
||||
export function timestamp(id: string): number {
|
||||
const prefix = id.split("_")[0]
|
||||
const hex = id.slice(prefix.length + 1, prefix.length + 13)
|
||||
return Number(BigInt(`0x${hex}`) / 0x1000n)
|
||||
}
|
||||
|
||||
export * as Identifier from "./id.js"
|
||||
@@ -2,7 +2,6 @@ export * as Job from "./job.js"
|
||||
|
||||
import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, Scope, SynchronizedRef } from "effect"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
@@ -94,7 +93,7 @@ type BlockStart =
|
||||
| { type: "wait"; wait: BlockWait }
|
||||
|
||||
export type StartInput = {
|
||||
id?: string
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
@@ -253,21 +252,20 @@ export const make = Effect.gen(function* () {
|
||||
const start: Interface["start"] = Effect.fnUntraced(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = input.id ?? Identifier.ascending("job")
|
||||
const started_at = yield* Clock.currentTimeMillis
|
||||
const done = yield* Deferred.make<Info>()
|
||||
const backgrounded = yield* Deferred.make<Info>()
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [StartResult, Map<string, Active>]> {
|
||||
const existing = jobs.get(id)
|
||||
const existing = jobs.get(input.id)
|
||||
if (existing?.info.status === "running") {
|
||||
return [{ info: snapshot(existing) }, jobs]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const job = {
|
||||
info: {
|
||||
id,
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
status: "running" as const,
|
||||
@@ -283,13 +281,13 @@ export const make = Effect.gen(function* () {
|
||||
consumed: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
|
||||
return [{ info: snapshot(job), scope }, new Map(jobs).set(input.id, job)]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result)
|
||||
yield* restore(input.run).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => settle(id, result.scope, exit)),
|
||||
Effect.flatMap((exit) => settle(input.id, result.scope, exit)),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(result.scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type Transport,
|
||||
type VersionNegotiationOptions,
|
||||
} from "@modelcontextprotocol/client"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { Cause, Clock, Duration, Effect, Exit, Schedule, Schema } from "effect"
|
||||
import { ConfigMCP } from "@opencode/schema/config/mcp"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { McpStdio } from "./stdio.js"
|
||||
@@ -49,6 +49,14 @@ const describe = (error: unknown) => {
|
||||
return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message
|
||||
}
|
||||
|
||||
// Overloaded or restarting servers and dropped connections usually recover within a second.
|
||||
// Fetch reports network failures as a TypeError carrying a system error code.
|
||||
// 501 means the server will never support the request.
|
||||
const isTransient = (error: unknown) =>
|
||||
error instanceof SdkHttpError
|
||||
? error.status === 408 || error.status === 429 || (error.status >= 500 && error.status !== 501)
|
||||
: error instanceof TypeError && "code" in error
|
||||
|
||||
export type { GetPromptResult, Prompt, ReadResourceResult, Resource, Tool }
|
||||
export type ResourceTemplate = ResourceTemplateType
|
||||
|
||||
@@ -159,6 +167,20 @@ export const connect = Effect.fnUntraced(function* (
|
||||
? `${describe(error)}: ${lastError}`
|
||||
: describe(error)
|
||||
|
||||
// Two more attempts, 250 ms and 1 s later, while the failure is transient and the deadline has not passed.
|
||||
const retrying = <A, E>(what: string, effect: Effect.Effect<A, E>, deadline = Infinity) =>
|
||||
Effect.retry(
|
||||
effect,
|
||||
Schedule.max([Schedule.exponential("250 millis", 4), Schedule.recurs(2)]).pipe(
|
||||
Schedule.setInputType<E>(),
|
||||
Schedule.while(({ input, now, duration }) =>
|
||||
!isTransient(input) || now + Duration.toMillis(duration) >= deadline
|
||||
? Effect.succeed(false)
|
||||
: Effect.logWarning(`retrying ${what}`, { server, error: describe(input) }).pipe(Effect.as(true)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const initialize = Effect.fnUntraced(function* (transport: Transport) {
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const client = new Client(clientInfo, {
|
||||
@@ -245,22 +267,28 @@ export const connect = Effect.fnUntraced(function* (
|
||||
const url = new URL(config.url)
|
||||
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
|
||||
if (addedCodemode) url.searchParams.set("codemode", "false")
|
||||
const open = (url: URL) => {
|
||||
const open = Effect.fnUntraced(function* (url: URL) {
|
||||
session.transport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
fetch,
|
||||
})
|
||||
return initialize(session.transport)
|
||||
}
|
||||
return yield* initialize(session.transport)
|
||||
})
|
||||
|
||||
return yield* open(url).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!addedCodemode || !(error instanceof SdkHttpError) || (error.status !== 400 && error.status !== 404))
|
||||
return Effect.fail(error)
|
||||
// Servers that reject unknown query params get one retry at the configured URL.
|
||||
return open(new URL(config.url))
|
||||
}),
|
||||
// Every attempt opens a fresh transport; no retry starts once the startup timeout has passed.
|
||||
const deadline = (yield* Clock.currentTimeMillis) + (config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT)
|
||||
return yield* retrying(
|
||||
"MCP connect",
|
||||
open(url).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!addedCodemode || !(error instanceof SdkHttpError) || (error.status !== 400 && error.status !== 404))
|
||||
return Effect.fail(error)
|
||||
// Servers that reject unknown query params get one retry at the configured URL.
|
||||
return open(new URL(config.url))
|
||||
}),
|
||||
),
|
||||
deadline,
|
||||
)
|
||||
}).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) {
|
||||
@@ -292,20 +320,23 @@ export const connect = Effect.fnUntraced(function* (
|
||||
Effect.tryPromise({ try: run, catch: failure }).pipe(
|
||||
Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
|
||||
)
|
||||
// Listing is read-only, so a transient failure is safe to retry.
|
||||
const list = <A>(what: string, run: () => Promise<A>) =>
|
||||
retrying(what, Effect.tryPromise({ try: run, catch: (error) => error })).pipe(
|
||||
Effect.mapError(failure),
|
||||
Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
|
||||
)
|
||||
|
||||
return {
|
||||
modern: client.getProtocolEra() === "modern",
|
||||
instructions: client.getInstructions()?.trim() || undefined,
|
||||
tools: () =>
|
||||
request("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
|
||||
tools: () => list("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
|
||||
prompts: () =>
|
||||
request("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
|
||||
list("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
|
||||
resources: () =>
|
||||
request("list MCP resources", () => client.listResources(undefined, catalog)).pipe(
|
||||
Effect.map((r) => r.resources),
|
||||
),
|
||||
list("list MCP resources", () => client.listResources(undefined, catalog)).pipe(Effect.map((r) => r.resources)),
|
||||
resourceTemplates: () =>
|
||||
request("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
|
||||
list("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
|
||||
Effect.map((r) => r.resourceTemplates),
|
||||
),
|
||||
readResource: (input) => {
|
||||
|
||||
@@ -129,48 +129,53 @@ const mimeToModality = (mime: string) => {
|
||||
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: Model.Capabilities) => {
|
||||
const modality = mimeToModality(mime)
|
||||
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`,
|
||||
}
|
||||
return `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`
|
||||
}
|
||||
|
||||
// Remote and provider-referenced media carry no local payload and never count toward the inline budget.
|
||||
const mediaBytes = (media: Media.Asset) => {
|
||||
if (media.source.type === "base64") return Buffer.byteLength(media.source.data)
|
||||
if (media.source.type === "bytes") return Math.ceil(media.source.data.byteLength / 3) * 4
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Replaces media with the returned text; messages without replacements are returned unchanged. */
|
||||
const replaceMedia = (
|
||||
messages: LLMRequest["messages"],
|
||||
replace: (media: { mime: string; name: string | undefined; bytes: () => number }) => string | undefined,
|
||||
) =>
|
||||
messages.map((message) => {
|
||||
const content = message.content.map((part) => {
|
||||
if (part.type === "media") {
|
||||
const text = replace({ mime: part.media.mediaType, name: part.filename, bytes: () => mediaBytes(part.media) })
|
||||
return text === undefined ? part : Message.text(text)
|
||||
}
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||
const result = part.result
|
||||
const value = result.value.map((item): Content => {
|
||||
if (item.type !== "file") return item
|
||||
const text = replace({ mime: item.mime, name: item.name, bytes: () => Buffer.byteLength(item.uri) })
|
||||
return text === undefined ? item : { type: "text", text }
|
||||
})
|
||||
return value.every((item, index) => item === result.value[index])
|
||||
? part
|
||||
: { ...part, result: { ...result, value } }
|
||||
})
|
||||
return content.every((part, index) => part === message.content[index])
|
||||
? message
|
||||
: new Message({ ...message, content })
|
||||
})
|
||||
|
||||
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: Model.Capabilities) =>
|
||||
messages.map((message) =>
|
||||
Message.make({
|
||||
...message,
|
||||
content: message.content.map((part) => {
|
||||
if (part.type === "media") {
|
||||
return unsupportedMedia(part.media.mediaType, part.filename, capabilities) ?? part
|
||||
}
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||
return {
|
||||
...part,
|
||||
result: {
|
||||
...part.result,
|
||||
value: part.result.value.map((item: Content) => {
|
||||
if (item.type !== "file") return item
|
||||
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
|
||||
}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
replaceMedia(messages, (media) => unsupportedMedia(media.mime, media.name, capabilities))
|
||||
|
||||
export const boundImages = (messages: LLMRequest["messages"]) => {
|
||||
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
|
||||
// Remote and provider-referenced media carry no local payload and never count toward the inline budget.
|
||||
const size = (media: Media.Asset) => {
|
||||
if (media.source.type === "base64") return Buffer.byteLength(media.source.data)
|
||||
if (media.source.type === "bytes") return Math.ceil(media.source.data.byteLength / 3) * 4
|
||||
return 0
|
||||
}
|
||||
const imageBytes = messages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
message.content.reduce((sum, part) => {
|
||||
if (part.type === "media" && isImage(part.media.mediaType)) return sum + size(part.media)
|
||||
if (part.type === "media" && isImage(part.media.mediaType)) return sum + mediaBytes(part.media)
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return sum
|
||||
return (
|
||||
sum +
|
||||
@@ -186,29 +191,11 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
|
||||
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
|
||||
|
||||
let removed = 0
|
||||
return messages.map((message) =>
|
||||
Message.make({
|
||||
...message,
|
||||
content: message.content.map((part) => {
|
||||
if (part.type === "media" && isImage(part.media.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
|
||||
removed += size(part.media)
|
||||
return Message.text(IMAGE_REMOVED)
|
||||
}
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||
return {
|
||||
...part,
|
||||
result: {
|
||||
...part.result,
|
||||
value: part.result.value.map((item: Content) => {
|
||||
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item
|
||||
removed += Buffer.byteLength(item.uri)
|
||||
return { type: "text" as const, text: IMAGE_REMOVED }
|
||||
}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return replaceMedia(messages, (media) => {
|
||||
if (!isImage(media.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return
|
||||
removed += media.bytes()
|
||||
return IMAGE_REMOVED
|
||||
})
|
||||
}
|
||||
|
||||
type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Context, Duration, Effect, Layer, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Identifier } from "@opencode/schema/identifier"
|
||||
import { FileRetention } from "./file-retention.js"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
@@ -15,6 +15,8 @@ export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "tool-output"
|
||||
|
||||
export const fileName = (timestamp?: number) => "tool_" + Identifier.create(false, timestamp)
|
||||
|
||||
type Result = Tool.NormalizedResult
|
||||
|
||||
type Limits = {
|
||||
@@ -81,7 +83,7 @@ const layer = Layer.effect(
|
||||
kept.push(line)
|
||||
bytes += size
|
||||
}
|
||||
const file = path.join(directory, Identifier.ascending("tool"))
|
||||
const file = path.join(directory, fileName())
|
||||
yield* fs.ensureDir(directory).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
const shown = kept.length > 0 ? `lines 1-${kept.length}` : "0 lines"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
export * as ConfigMigrateV1 from "./migrate.js"
|
||||
|
||||
import { Info } from "@opencode/schema/config"
|
||||
import { ConfigAgent } from "@opencode/schema/config/agent"
|
||||
import { Schema } from "effect"
|
||||
import { ConfigV1 } from "./config.js"
|
||||
import { ConfigAgentV1 } from "./agent.js"
|
||||
import { ConfigCommandV1 } from "./command.js"
|
||||
import { ConfigMCPV1 } from "./mcp.js"
|
||||
@@ -14,102 +12,16 @@ import { Provider } from "../../provider.js"
|
||||
import { Model } from "../../model.js"
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
|
||||
const encodeInfo = Schema.encodeSync(Info)
|
||||
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
|
||||
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
|
||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
return encodeInfo(
|
||||
decodeInfo(
|
||||
JSON.stringify({
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: modelSelection(info.model),
|
||||
default_agent: info.default_agent,
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify"
|
||||
? "notify"
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
permissions: permissions(info.permission, info.tools),
|
||||
agents: agents(info),
|
||||
snapshots: info.snapshot,
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
auto: info.compaction.auto,
|
||||
prune: info.compaction.prune,
|
||||
keep: {
|
||||
tokens: info.compaction.preserve_recent_tokens,
|
||||
},
|
||||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
experimental: experimental(info),
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
),
|
||||
providers: providers(info.provider),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function experimental(info: typeof ConfigV1.Info.Type) {
|
||||
const policies = [
|
||||
...(info.enabled_providers === undefined
|
||||
? []
|
||||
: [
|
||||
{ action: "provider.use" as const, resource: "*", effect: "deny" as const },
|
||||
...info.enabled_providers.map((resource) => ({
|
||||
action: "provider.use" as const,
|
||||
resource: providerID(resource),
|
||||
effect: "allow" as const,
|
||||
})),
|
||||
]),
|
||||
...(info.disabled_providers ?? []).map((resource) => ({
|
||||
action: "provider.use" as const,
|
||||
resource: providerID(resource),
|
||||
effect: "deny" as const,
|
||||
})),
|
||||
]
|
||||
if (info.experimental?.subagent_depth === undefined && !policies.length) return
|
||||
return {
|
||||
subagent_depth: info.experimental?.subagent_depth,
|
||||
policies: policies.length ? policies : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
|
||||
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
|
||||
tools ?? {},
|
||||
).map(([action, enabled]) => ({
|
||||
action: normalizeAction(action),
|
||||
resource: "*",
|
||||
effect: enabled ? ("allow" as const) : ("deny" as const),
|
||||
}))
|
||||
for (const [key, rule] of Object.entries(info ?? {})) {
|
||||
if (!rule) continue
|
||||
function permissions(info?: ConfigPermissionV1.Info) {
|
||||
const rules = Object.entries(info ?? {}).flatMap(([key, rule]) => {
|
||||
if (!rule) return []
|
||||
const action = normalizeAction(key)
|
||||
if (typeof rule === "string") {
|
||||
rules.push({ action, resource: "*", effect: rule })
|
||||
continue
|
||||
}
|
||||
rules.push(...Object.entries(rule).map(([resource, effect]) => ({ action, resource, effect })))
|
||||
}
|
||||
if (typeof rule === "string") return [{ action, resource: "*", effect: rule }]
|
||||
return Object.entries(rule).map(([resource, effect]) => ({ action, resource, effect }))
|
||||
})
|
||||
return rules.length ? rules : undefined
|
||||
}
|
||||
|
||||
@@ -121,23 +33,6 @@ export function normalizeAction(action: string) {
|
||||
return action
|
||||
}
|
||||
|
||||
function agents(info: typeof ConfigV1.Info.Type) {
|
||||
const entries = [
|
||||
...Object.entries(info.agent ?? {}),
|
||||
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
||||
]
|
||||
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
||||
const small = modelSelection(info.small_model)
|
||||
if (!small) return entries.length ? result : undefined
|
||||
return {
|
||||
...result,
|
||||
title: {
|
||||
model: small,
|
||||
...result.title,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
const body = {
|
||||
...info.options,
|
||||
@@ -162,23 +57,17 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
)
|
||||
}
|
||||
|
||||
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([id, command]) => [
|
||||
id,
|
||||
{
|
||||
template: command.template,
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: modelSelection(command.model, command.variant),
|
||||
subagent: command.subtask,
|
||||
},
|
||||
]),
|
||||
)
|
||||
export function migrateCommand(command: ConfigCommandV1.Info) {
|
||||
return {
|
||||
template: command.template,
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: modelSelection(command.model, command.variant),
|
||||
subagent: command.subtask,
|
||||
}
|
||||
}
|
||||
|
||||
function modelSelection(input?: string, variant?: string) {
|
||||
export function modelSelection(input?: string, variant?: string) {
|
||||
if (input === undefined || !/^[^/#]+\/[^#]+$/.test(input)) return undefined
|
||||
const separator = input.indexOf("/")
|
||||
return {
|
||||
@@ -188,17 +77,6 @@ function modelSelection(input?: string, variant?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function mcp(info: typeof ConfigV1.Info.Type) {
|
||||
const servers = Object.fromEntries(
|
||||
Object.entries(info.mcp ?? {}).flatMap(([name, server]) =>
|
||||
"type" in server ? [[name, migrateMcp(server)] as const] : [],
|
||||
),
|
||||
)
|
||||
const timeout = info.experimental?.mcp_timeout
|
||||
if (!timeout && !Object.keys(servers).length) return undefined
|
||||
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
||||
}
|
||||
|
||||
export function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
||||
if (info.type === "local")
|
||||
@@ -226,18 +104,6 @@ export function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
}
|
||||
}
|
||||
|
||||
function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).flatMap(([name, provider]) => {
|
||||
const id = providerID(name)
|
||||
// If both names are present, keep the settings under the current name and ignore the old one.
|
||||
if (id !== name && info[id]) return []
|
||||
return [[id, migrateProvider(name, provider)]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
|
||||
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
|
||||
@@ -294,7 +160,7 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename these only while migrating unambiguous V1 fields.
|
||||
// Renames retired provider IDs in V1 fields and the shared top-level model.
|
||||
export function providerID(input: string) {
|
||||
if (input === "azure-cognitive-services") return "azure"
|
||||
if (input === "google-vertex-anthropic") return "google-vertex"
|
||||
|
||||
@@ -17,7 +17,6 @@ import { Project } from "./project.js"
|
||||
import { Worktree } from "@opencode/schema/worktree"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
import { WorktreeGit } from "./worktree/git.js"
|
||||
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
import { ProjectTable } from "./project/sql.js"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
@@ -111,7 +110,6 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const processService = yield* AppProcess.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const gitStrategy = yield* WorktreeGit.make
|
||||
const project = Effect.fnUntraced(function* (projectID: Project.ID) {
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
|
||||
if (!row) return yield* new Project.NotFoundError({ projectID })
|
||||
@@ -341,7 +339,7 @@ const layer = Layer.effect(
|
||||
export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.global> = Node.makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, LocationServiceMap.node],
|
||||
deps: [FSUtil.node, Bus.node, Database.node, AppProcess.node, LocationServiceMap.node],
|
||||
})
|
||||
|
||||
function operationError(strategy: StrategyID, operation: string, error: unknown) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Global } from "@opencode/util/global"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { AgentPlugin } from "@opencode/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { ConfigMigrateV1 } from "@opencode/core/v1/config/migrate"
|
||||
import { ConfigNormalize } from "@opencode/core/config/normalize"
|
||||
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { tmpdir, tmpdirScoped } from "../fixture/tmpdir"
|
||||
@@ -24,6 +24,12 @@ import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
function migrateV1(input: unknown) {
|
||||
const result = ConfigNormalize.normalize(input)
|
||||
if (result.type !== "normalized") throw new Error("expected normalized config")
|
||||
return result.encoded
|
||||
}
|
||||
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
|
||||
...Agent.Info.default(Agent.ID.make("test")).permissions,
|
||||
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
|
||||
@@ -171,7 +177,7 @@ permissions:
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode(
|
||||
ConfigMigrateV1.migrate({
|
||||
migrateV1({
|
||||
permission: {
|
||||
bash: "ask",
|
||||
edit: "ask",
|
||||
@@ -752,7 +758,7 @@ function loadHomePermissions(home: string) {
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode(
|
||||
ConfigMigrateV1.migrate({
|
||||
migrateV1({
|
||||
permission: {
|
||||
external_directory: {
|
||||
"~/p/**": "allow",
|
||||
|
||||
@@ -11,8 +11,7 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { ConfigMigrateV1 } from "@opencode/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode/core/v1/config/config"
|
||||
import { ConfigV1 } from "../fixture/v1-config/config"
|
||||
import { ConfigNormalize } from "@opencode/core/config/normalize"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
@@ -30,6 +29,18 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
|
||||
const decodeInfo = Schema.decodeUnknownSync(Info, {
|
||||
errors: "all",
|
||||
onExcessProperty: "ignore",
|
||||
propertyOrder: "original",
|
||||
})
|
||||
const encodeInfo = Schema.encodeSync(Info)
|
||||
|
||||
function migrateV1(input: unknown) {
|
||||
const result = ConfigNormalize.normalize(input)
|
||||
if (result.type !== "normalized") throw new Error("expected normalized config")
|
||||
return encodeInfo(decodeInfo(result.encoded))
|
||||
}
|
||||
|
||||
function inFixture(root: string, target: string) {
|
||||
const relative = path.relative(root, target)
|
||||
@@ -634,38 +645,21 @@ describe("Config", () => {
|
||||
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
migrateV1(JSON.parse(JSON.stringify(info)))
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
}, 30_000)
|
||||
|
||||
test("migrates the v1 experimental subagent depth", () => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
})
|
||||
|
||||
test("migrates the v1 small model to the title agent", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
}).agents?.title,
|
||||
).toEqual({
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
})
|
||||
expect(migrateV1({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
})
|
||||
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
expect(migrateV1({ autoupdate: false }).update).toBe("disable")
|
||||
expect(migrateV1({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(migrateV1({ autoupdate: true }).update).toBe("auto")
|
||||
expect(migrateV1({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the native auto update policy", () => {
|
||||
@@ -678,7 +672,7 @@ describe("Config", () => {
|
||||
|
||||
test("migrates v1 provider lists to policies", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
migrateV1({
|
||||
enabled_providers: ["anthropic", "openai"],
|
||||
disabled_providers: ["openai"],
|
||||
}).experimental?.policies,
|
||||
@@ -688,13 +682,13 @@ describe("Config", () => {
|
||||
{ action: "provider.use", resource: "openai", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||
])
|
||||
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||
expect(migrateV1({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
])
|
||||
})
|
||||
|
||||
test("migrates v1 provider setup options into AISDK settings", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
const migrated = migrateV1({
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
@@ -719,7 +713,7 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
test("renames old provider IDs while migrating v1 configuration", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
const migrated = migrateV1({
|
||||
model: "azure-cognitive-services/deployment",
|
||||
enabled_providers: ["google-vertex-anthropic"],
|
||||
disabled_providers: ["azure-cognitive-services"],
|
||||
@@ -768,7 +762,7 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
const migrated = migrateV1({
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
@@ -787,7 +781,7 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
test("ignores old provider IDs when the current provider ID is configured", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
const migrated = migrateV1({
|
||||
provider: {
|
||||
azure: { models: { current: {} } },
|
||||
"azure-cognitive-services": { models: { legacy: {} } },
|
||||
@@ -801,7 +795,7 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
const migrated = migrateV1({
|
||||
provider: {
|
||||
"google-vertex-anthropic": {
|
||||
models: { claude: {} },
|
||||
@@ -816,7 +810,7 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
test("migrates v1 interleaved fields to compatibility", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
const migrated = migrateV1({
|
||||
provider: {
|
||||
custom: {
|
||||
models: {
|
||||
@@ -838,7 +832,7 @@ describe("Config", () => {
|
||||
for (const subtask of [true, false]) {
|
||||
test(`migrates v1 command configuration with subtask: ${subtask}`, () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
migrateV1({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
@@ -864,7 +858,7 @@ describe("Config", () => {
|
||||
|
||||
test("normalizes renamed permission actions when migrating v1 permissions", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
migrateV1({
|
||||
permission: {
|
||||
task: "ask",
|
||||
bash: { "git status": "allow", "*": "deny" },
|
||||
|
||||
@@ -149,6 +149,21 @@ describe("ConfigNormalize", () => {
|
||||
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
|
||||
})
|
||||
|
||||
test("renames legacy provider IDs in the top-level model", () => {
|
||||
expect(normalized({ model: "google-vertex-anthropic/claude#high" }).encoded.model).toEqual({
|
||||
providerID: "google-vertex",
|
||||
model: "claude",
|
||||
variant: "high",
|
||||
})
|
||||
expect(
|
||||
normalized({ model: { providerID: "azure-cognitive-services", model: "deployment" } }).encoded.model,
|
||||
).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(normalized({ model: "anthropic/claude" }).encoded.model).toEqual({
|
||||
providerID: "anthropic",
|
||||
model: "claude",
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates the legacy small model to the title agent", () => {
|
||||
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
|
||||
expect(result.encoded.agents).toEqual({
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
export * as ConfigAttachmentV1 from "./attachment.js"
|
||||
export * as ConfigAttachmentV1 from "./attachment"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../../schema.js"
|
||||
import { PositiveInt } from "@opencode/core/schema"
|
||||
|
||||
export const Image = Schema.Struct({
|
||||
auto_resize: Schema.optional(Schema.Boolean).annotate({
|
||||
+14
-23
@@ -1,27 +1,20 @@
|
||||
export * as ConfigV1 from "./config.js"
|
||||
export * as ConfigV1 from "./config"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigReference } from "@opencode/schema/config/reference"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema.js"
|
||||
import { ConfigAgentV1 } from "./agent.js"
|
||||
import { ConfigAttachmentV1 } from "./attachment.js"
|
||||
import { ConfigCommandV1 } from "./command.js"
|
||||
import { ConfigFormatterV1 } from "./formatter.js"
|
||||
import { ConfigLayoutV1 } from "./layout.js"
|
||||
import { ConfigLSPV1 } from "./lsp.js"
|
||||
import { ConfigMCPV1 } from "./mcp.js"
|
||||
import { ConfigPermissionV1 } from "./permission.js"
|
||||
import { ConfigPluginV1 } from "./plugin.js"
|
||||
import { ConfigProviderV1 } from "./provider.js"
|
||||
import { ConfigServerV1 } from "./server.js"
|
||||
import { ConfigSkillsV1 } from "./skills.js"
|
||||
|
||||
export type Layout = ConfigLayoutV1.Layout
|
||||
|
||||
export const WellKnown = Schema.Struct({
|
||||
config: Schema.optional(Schema.Json),
|
||||
remote_config: Schema.optional(Schema.Json),
|
||||
})
|
||||
import { NonNegativeInt, PositiveInt } from "@opencode/core/schema"
|
||||
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
|
||||
import { ConfigAttachmentV1 } from "./attachment"
|
||||
import { ConfigCommandV1 } from "@opencode/core/v1/config/command"
|
||||
import { ConfigFormatterV1 } from "./formatter"
|
||||
import { ConfigLayoutV1 } from "./layout"
|
||||
import { ConfigLSPV1 } from "./lsp"
|
||||
import { ConfigMCPV1 } from "@opencode/core/v1/config/mcp"
|
||||
import { ConfigPermissionV1 } from "@opencode/core/v1/config/permission"
|
||||
import { ConfigPluginV1 } from "@opencode/core/v1/config/plugin"
|
||||
import { ConfigProviderV1 } from "@opencode/core/v1/config/provider"
|
||||
import { ConfigServerV1 } from "./server"
|
||||
import { ConfigSkillsV1 } from "./skills"
|
||||
|
||||
const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
|
||||
identifier: "LogLevel",
|
||||
@@ -187,5 +180,3 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "Config" })
|
||||
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export * as ConfigFormatterV1 from "./formatter.js"
|
||||
export * as ConfigFormatterV1 from "./formatter"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export * as ConfigLayoutV1 from "./layout.js"
|
||||
export * as ConfigLayoutV1 from "./layout"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigLSPV1 from "./lsp.js"
|
||||
export * as ConfigLSPV1 from "./lsp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
export * as ConfigServerV1 from "./server.js"
|
||||
export * as ConfigServerV1 from "./server"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../../schema.js"
|
||||
import { PositiveInt } from "@opencode/core/schema"
|
||||
|
||||
export const Server = Schema.Struct({
|
||||
port: Schema.optional(PositiveInt).annotate({
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export * as ConfigSkillsV1 from "./skills.js"
|
||||
export * as ConfigSkillsV1 from "./skills"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("Job", () => {
|
||||
const jobs = yield* Job.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
id: "job_metadata",
|
||||
type: "test",
|
||||
metadata: { durable: false },
|
||||
run: Deferred.await(latch).pipe(Effect.as("done")),
|
||||
@@ -69,6 +70,7 @@ describe("Job", () => {
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const job = yield* jobs.start({
|
||||
id: "job_failure",
|
||||
type: "test",
|
||||
run: Effect.failCause(
|
||||
Cause.combine(
|
||||
@@ -158,7 +160,11 @@ describe("Job", () => {
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
|
||||
const job = yield* jobs.start({
|
||||
id: "job_finished",
|
||||
type: "test",
|
||||
run: Deferred.await(latch).pipe(Effect.as("done")),
|
||||
})
|
||||
const waiting = yield* jobs
|
||||
.block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
|
||||
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
|
||||
@@ -177,7 +183,11 @@ describe("Job", () => {
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
|
||||
const job = yield* jobs.start({
|
||||
id: "job_backgrounded",
|
||||
type: "test",
|
||||
run: Deferred.await(latch).pipe(Effect.as("done")),
|
||||
})
|
||||
const waiting = yield* jobs
|
||||
.block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
|
||||
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
|
||||
@@ -247,7 +257,12 @@ describe("Job", () => {
|
||||
shellID: "shell_background",
|
||||
command: "echo done",
|
||||
}
|
||||
const job = yield* jobs.start({ type: "shell", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
|
||||
const job = yield* jobs.start({
|
||||
id: recovery.shellID,
|
||||
type: "shell",
|
||||
recovery,
|
||||
run: Deferred.await(latch).pipe(Effect.as("done")),
|
||||
})
|
||||
|
||||
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
|
||||
const background = yield* jobs.background(job.id)
|
||||
@@ -287,7 +302,12 @@ describe("Job", () => {
|
||||
agent: "explore",
|
||||
description: "Explore background recovery",
|
||||
}
|
||||
const job = yield* jobs.start({ type: "subagent", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
|
||||
const job = yield* jobs.start({
|
||||
id: recovery.childSessionID,
|
||||
type: "subagent",
|
||||
recovery,
|
||||
run: Deferred.await(latch).pipe(Effect.as("done")),
|
||||
})
|
||||
const waiting = yield* jobs
|
||||
.block({ id: job.id, sessionID: parentSessionID })
|
||||
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
|
||||
@@ -313,6 +333,7 @@ describe("Job", () => {
|
||||
const jobs = yield* Job.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
id: "shell_error",
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
@@ -338,6 +359,7 @@ describe("Job", () => {
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const job = yield* jobs.start({
|
||||
id: "shell_immediate_error",
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
@@ -362,6 +384,7 @@ describe("Job", () => {
|
||||
const scope = yield* Scope.make()
|
||||
const previous = yield* Job.make.pipe(Scope.provide(scope))
|
||||
const job = yield* previous.start({
|
||||
id: "shell_restart",
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
@@ -387,6 +410,7 @@ describe("Job", () => {
|
||||
const jobs = yield* Job.Service
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
id: "ses_interrupted_child",
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
@@ -414,6 +438,7 @@ describe("Job", () => {
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const jobs = yield* Job.make.pipe(Scope.provide(scope))
|
||||
const job = yield* jobs.start({
|
||||
id: "job_abandoned",
|
||||
type: "test",
|
||||
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
|
||||
})
|
||||
|
||||
@@ -1048,7 +1048,7 @@ for (const entry of [
|
||||
{ name: "second 400", status: 400, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "401", status: 401, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "403", status: 403, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "500", status: 500, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "501", status: 501, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "user codemode=true", status: 404, query: "?codemode=true", codemode: undefined, attempts: 1 },
|
||||
{ name: "user codemode=false", status: 404, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
||||
{ name: "empty user codemode", status: 404, query: "?codemode=", codemode: undefined, attempts: 1 },
|
||||
|
||||
@@ -279,7 +279,7 @@ describe("Npm.add", () => {
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const mutableEntry = yield* npm.add(mutable)
|
||||
const pinnedEntry = yield* npm.add(pinned)
|
||||
yield* npm.add(pinned)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses"
|
||||
import { AIError, HttpContext, RateLimitError } from "@opencode/ai"
|
||||
import { classifyProviderFailure } from "@opencode/ai/provider-error"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, DateTime, Deferred, Effect, Schedule } from "effect"
|
||||
import { DateTime, Deferred, Effect, Schedule } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose"
|
||||
import { App } from "@opencode/core/app"
|
||||
|
||||
@@ -14,7 +14,6 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* GoogleVertexPlugin.effect(host)
|
||||
})
|
||||
@@ -307,7 +306,6 @@ describe("GoogleVertexPlugin", () => {
|
||||
|
||||
it.effect("trims model IDs before selecting language models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Agent } from "@opencode/schema/agent"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Location } from "@opencode/core/location"
|
||||
|
||||
@@ -63,6 +63,73 @@ describe("SessionModelRequest.unsupportedParts", () => {
|
||||
const message = Message.user({ type: "media", media: Media.base64("aGVsbG8=", "image/png") })
|
||||
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
|
||||
})
|
||||
|
||||
test("returns the same messages when nothing is unsupported", () => {
|
||||
const messages = [
|
||||
Message.user([Message.text("hi"), { type: "media", media: Media.base64("aGVsbG8=", "image/png") }]),
|
||||
Message.assistant("hello"),
|
||||
Message.tool(
|
||||
ToolResultPart.make({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "logo.png" }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
const result = unsupportedParts(messages, capabilities(["text", "image"]))
|
||||
expect(result).toHaveLength(messages.length)
|
||||
result.forEach((message, index) => expect(message).toBe(messages[index]))
|
||||
})
|
||||
|
||||
test("rebuilds only messages with unsupported media", () => {
|
||||
const text = Message.user("plain")
|
||||
const supported = Message.tool(
|
||||
ToolResultPart.make({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: { type: "content", value: [{ type: "text", text: "no files" }] },
|
||||
}),
|
||||
)
|
||||
const tool = ToolResultPart.make({
|
||||
id: "call_2",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Read" },
|
||||
{ type: "file", uri: "data:application/pdf;base64,JVBERg==", mime: "application/pdf" },
|
||||
],
|
||||
},
|
||||
})
|
||||
const unsupported = Message.make({ role: "tool", content: [tool, Message.text("caption")] })
|
||||
const result = unsupportedParts([text, supported, unsupported], capabilities(["text"]))
|
||||
|
||||
expect(result[0]).toBe(text)
|
||||
expect(result[1]).toBe(supported)
|
||||
expect(result[2]).not.toBe(unsupported)
|
||||
expect(result[2]).toBeInstanceOf(Message)
|
||||
expect(result[2]?.content[1]).toEqual(Message.text("caption"))
|
||||
expect(result[2]?.content[0]).toMatchObject({
|
||||
type: "tool-result",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Read" },
|
||||
{ type: "text", text: "ERROR: Cannot read pdf (this model does not support pdf input). Inform the user." },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(tool.result).toMatchObject({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Read" },
|
||||
{ type: "file", mime: "application/pdf" },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionModelRequest.boundImages", () => {
|
||||
@@ -73,16 +140,22 @@ describe("SessionModelRequest.boundImages", () => {
|
||||
|
||||
test("replaces oldest images until the retained payload reaches the target", () => {
|
||||
const image = "a".repeat(9 * 1024 * 1024)
|
||||
const text = Message.user("plain")
|
||||
const third = Message.user({ type: "media", media: Media.base64(image, "image/png"), filename: "third.png" })
|
||||
const messages = [
|
||||
Message.user({ type: "media", media: Media.base64(image, "image/png"), filename: "first.png" }),
|
||||
text,
|
||||
Message.user({ type: "media", media: Media.base64(image, "image/png"), filename: "second.png" }),
|
||||
Message.user({ type: "media", media: Media.base64(image, "image/png"), filename: "third.png" }),
|
||||
third,
|
||||
]
|
||||
const result = boundImages(messages)
|
||||
|
||||
expect(result[0]?.content[0]).toMatchObject({ type: "text" })
|
||||
expect(result[1]?.content[0]).toMatchObject({ type: "text" })
|
||||
expect(result[2]?.content[0]).toMatchObject({ type: "media", filename: "third.png" })
|
||||
expect(result[0]).toBeInstanceOf(Message)
|
||||
expect(result[0]?.content).toEqual([Message.text(expect.stringContaining("image was removed"))])
|
||||
expect(result[1]).toBe(text)
|
||||
expect(result[2]).not.toBe(messages[2])
|
||||
expect(result[2]?.content[0]).toMatchObject({ type: "text" })
|
||||
expect(result[3]).toBe(third)
|
||||
})
|
||||
|
||||
test("replaces images nested in tool results", () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ test("execute describes invariant Code Mode behavior", () => {
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.allSettled`.",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ToolOutput } from "@opencode/core/tool-output"
|
||||
import type { Tool } from "@opencode/core/tool"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Identifier } from "@opencode/core/id/id"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
@@ -141,8 +140,8 @@ describe("ToolOutput", () => {
|
||||
withStore((output, fs, root) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(root, ToolOutput.DIRECTORY)
|
||||
const old = path.join(directory, Identifier.create("tool", "ascending", 2 ** 36 - 1))
|
||||
const recent = path.join(directory, Identifier.create("tool", "ascending", 2 ** 36 + 1))
|
||||
const old = path.join(directory, ToolOutput.fileName(2 ** 36 - 1))
|
||||
const recent = path.join(directory, ToolOutput.fileName(2 ** 36 + 1))
|
||||
yield* fs.ensureDir(directory)
|
||||
yield* fs.writeFileString(old, "old")
|
||||
yield* fs.writeFileString(recent, "recent")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user