mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-04 14:36:18 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e9cbaca9 | ||
|
|
b03d760f43 | ||
|
|
b73e75fb6a | ||
|
|
08c15ec1cd | ||
|
|
48f3c2e923 | ||
|
|
3d94a90978 | ||
|
|
2c33922412 | ||
|
|
7e42a897bc | ||
|
|
d8663e1fe8 | ||
|
|
a6b10e3e87 | ||
|
|
3400375fb3 |
No files matched your search
@@ -45,14 +45,16 @@ runs:
|
||||
- name: Get cache directory
|
||||
id: cache
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Bun dependencies
|
||||
id: bun-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }}
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
@@ -75,4 +77,4 @@ runs:
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
branches: [dev, v2]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: typecheck
|
||||
|
||||
@@ -451,6 +451,9 @@ const AnthropicStreamDelta = Schema.Struct({
|
||||
signature: Schema.optional(Schema.String),
|
||||
stop_reason: optionalNull(Schema.String),
|
||||
stop_sequence: optionalNull(Schema.String),
|
||||
stop_details: optionalNull(
|
||||
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
|
||||
),
|
||||
})
|
||||
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
|
||||
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
|
||||
@@ -1417,10 +1420,14 @@ const onMessageDelta = (
|
||||
stopSequence === null || stopSequence === undefined
|
||||
? state.pendingFinish?.providerMetadata
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence })
|
||||
const category = event.delta?.stop_details?.category
|
||||
const explanation = event.delta?.stop_details?.explanation
|
||||
return {
|
||||
reason: {
|
||||
normalized: mapFinishReason(stopReason),
|
||||
raw: stopReason,
|
||||
...(category ? { category } : {}),
|
||||
...(explanation ? { explanation } : {}),
|
||||
},
|
||||
providerMetadata: finishMetadata,
|
||||
}
|
||||
|
||||
@@ -264,6 +264,10 @@ export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
export const FinishReasonDetails = Schema.Struct({
|
||||
normalized: FinishReason,
|
||||
raw: Schema.optional(Schema.String),
|
||||
/** The provider's policy area for a content-filter finish, such as `cyber`. */
|
||||
category: Schema.optional(Schema.String),
|
||||
/** The provider's human-readable reason for a content-filter finish. */
|
||||
explanation: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "LLM.FinishReasonDetails" })
|
||||
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
|
||||
|
||||
|
||||
@@ -1673,6 +1673,40 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("carries a refusal's category and explanation on the content-filter finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const refusal = (stop_details: unknown) =>
|
||||
LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "message_delta", delta: { stop_reason: "refusal", stop_details }, usage: { output_tokens: 0 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* refusal({
|
||||
type: "refusal",
|
||||
category: "cyber",
|
||||
explanation: "This request was declined because it could enable cyber harm.",
|
||||
})).finishReason,
|
||||
).toEqual({
|
||||
normalized: "content-filter",
|
||||
raw: "refusal",
|
||||
category: "cyber",
|
||||
explanation: "This request was declined because it could enable cyber harm.",
|
||||
})
|
||||
expect((yield* refusal({ type: "refusal", category: null, explanation: null })).finishReason).toEqual({
|
||||
normalized: "content-filter",
|
||||
raw: "refusal",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -6,13 +6,18 @@ import {
|
||||
type AgentNotificationMethod,
|
||||
type AgentRequestHandlersByMethod,
|
||||
type AgentRequestMethod,
|
||||
type JsonRpcId,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/promise"
|
||||
import { Cause, Deferred, Effect, type Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Ref, type Scope } from "effect"
|
||||
import { ACPCatalog } from "./catalog"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { ACPService } from "./service"
|
||||
import { ACPSessions } from "./sessions"
|
||||
import { ACPTurn } from "./turn"
|
||||
|
||||
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
|
||||
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
|
||||
@@ -28,13 +33,20 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const handler = Effect.fn(name)(
|
||||
(ctx: AgentHandlerContext<Params>) =>
|
||||
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
|
||||
// Catalog failures arrive typed and are classified like promise rejections.
|
||||
Effect.catch(ACPError.classify),
|
||||
Effect.catchTags({
|
||||
ACPCatalogLoadError: (error) => ACPPromise.classify(error.cause),
|
||||
ACPCatalogNotReadyError: (error) => Effect.die(error),
|
||||
}),
|
||||
Effect.mapError((error) => (error instanceof RequestError ? error : ACPError.toRequestError(error))),
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
|
||||
Effect.catchDefect((defect) => Effect.fail(ACPError.toRequestError(ACPError.fromUnknown(defect)))),
|
||||
)
|
||||
return (ctx: AgentHandlerContext<Params>) => run(handler(ctx))
|
||||
return (ctx: AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }) => {
|
||||
if (ctx.requestId === undefined) return run(handler(ctx))
|
||||
return run(
|
||||
handler(ctx).pipe(Effect.provideService(ACPConnection.Responded, acp.connection.responded(ctx.requestId))),
|
||||
)
|
||||
}
|
||||
}
|
||||
const app = agent({ name: "opencode" })
|
||||
const request = <Method extends AgentRequestMethod>(
|
||||
@@ -76,7 +88,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
)
|
||||
request(
|
||||
"session/close",
|
||||
handle((service, ctx) => ACPError.promise(() => service.closeSession(ctx.params))),
|
||||
handle((service, ctx) => service.closeSession(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/fork",
|
||||
@@ -94,15 +106,19 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
// `stopReason: "cancelled"`.
|
||||
request(
|
||||
"session/prompt",
|
||||
handle((service, ctx) => ACPError.promise(() => service.prompt(ctx.params, ctx.signal))),
|
||||
handle((service, ctx) => service.prompt(ctx.params, ctx.signal)),
|
||||
)
|
||||
notification(
|
||||
"session/cancel",
|
||||
handle((service, ctx) => ACPError.promise(() => service.cancel(ctx.params))),
|
||||
handle((service, ctx) => service.cancel(ctx.params)),
|
||||
)
|
||||
const connection = app.connect(stream)
|
||||
yield* Deferred.succeed(ready, yield* ACPService.make({ client, connection, catalog, run }))
|
||||
return connection
|
||||
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 })
|
||||
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
|
||||
})
|
||||
|
||||
const spanName = (method: string) => `cli.acp.${method.replaceAll("/", ".")}`
|
||||
|
||||
@@ -3,12 +3,18 @@ import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Context, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
|
||||
// ACP runs these itself; they take precedence over server commands with the same name.
|
||||
export const builtinCommands = new Map([
|
||||
["compact", { description: "Compact the session", start: "compaction" as const }],
|
||||
])
|
||||
|
||||
export type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ModelInfo[]
|
||||
readonly defaultModel: ModelRef
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
/** Server commands, without those shadowed by a built-in. */
|
||||
readonly commands: CommandInfo[]
|
||||
}
|
||||
|
||||
@@ -176,7 +182,7 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data,
|
||||
commands: commandResult.data.filter((command) => !builtinCommands.has(command.name)),
|
||||
} satisfies Catalog
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { ModelRef } from "@opencode/client/promise"
|
||||
import { builtinCommands, type Catalog } from "./catalog"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
@@ -25,6 +27,34 @@ export type ModelSelection = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
/** A session's model and mode. Unset fields follow the catalog defaults. */
|
||||
export type Selection = {
|
||||
readonly model?: ModelRef
|
||||
readonly modeID?: string
|
||||
}
|
||||
|
||||
export function currentModel(catalog: Catalog, selection: Selection) {
|
||||
return selection.model ?? catalog.defaultModel
|
||||
}
|
||||
|
||||
export function configOptions(catalog: Catalog, selection: Selection) {
|
||||
const model = currentModel(catalog, selection)
|
||||
return buildConfigOptions({
|
||||
providers: catalog.providers,
|
||||
currentModel: { providerID: model.providerID, modelID: model.id },
|
||||
currentVariant: model.variant,
|
||||
modes: catalog.modes,
|
||||
currentModeId: selection.modeID ?? catalog.defaultModeID,
|
||||
})
|
||||
}
|
||||
|
||||
export function availableCommands(catalog: Catalog) {
|
||||
return [
|
||||
...catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
...Array.from(builtinCommands, ([name, command]) => ({ name, description: command.description })),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildConfigOptions(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
|
||||
@@ -1,52 +1,76 @@
|
||||
import {
|
||||
methods,
|
||||
type AgentConnection,
|
||||
type AgentApp,
|
||||
type AnyMessage,
|
||||
type JsonRpcId,
|
||||
type RequestError,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SendRequestOptions,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Context, type Effect } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
import { Context, Deferred, Effect } from "effect"
|
||||
import type { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
type Failure = ACPError.Error | RequestError
|
||||
/**
|
||||
* Completes once the response to the request being handled is written, so messages sent afterwards follow it.
|
||||
* Interrupts when the request fails. Outside a request it completes immediately.
|
||||
*/
|
||||
export const Responded = Context.Reference<Effect.Effect<void>>("@opencode/cli/acp/Connection/Responded", {
|
||||
defaultValue: () => Effect.void,
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, Failure>
|
||||
/** Interrupting the request cancels it on the client. */
|
||||
readonly requestPermission: (params: RequestPermissionRequest) => Effect.Effect<RequestPermissionResponse, Failure>
|
||||
readonly extNotification: (method: string, params: Record<string, unknown>) => Effect.Effect<void, Failure>
|
||||
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
/** Interruption cancels the client's request. */
|
||||
readonly requestPermission: (
|
||||
params: RequestPermissionRequest,
|
||||
) => Effect.Effect<RequestPermissionResponse, ACPError.Error | RequestError>
|
||||
readonly extNotification: (
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
/** Tracks an incoming request from now on and returns its `Responded`. */
|
||||
readonly responded: (requestId: JsonRpcId) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Connection") {}
|
||||
|
||||
export function service(connection: AgentConnection) {
|
||||
return Service.of({
|
||||
sessionUpdate: (params) => ACPError.promise(() => connection.client.notify(methods.client.session.update, params)),
|
||||
requestPermission: (params) =>
|
||||
ACPError.promise((signal) =>
|
||||
connection.client.request(methods.client.session.requestPermission, params, { cancellationSignal: signal }),
|
||||
),
|
||||
extNotification: (method, params) => ACPError.promise(() => connection.client.notify(method, params)),
|
||||
export function make(app: AgentApp, stream: Stream) {
|
||||
// Settled as each response is written to the stream, which serializes every outgoing message.
|
||||
const responses = new Map<JsonRpcId, Deferred.Deferred<void>>()
|
||||
const writer = stream.writable.getWriter()
|
||||
const agent = app.connect({
|
||||
readable: stream.readable,
|
||||
writable: new WritableStream<AnyMessage>({
|
||||
write: async (message) => {
|
||||
await writer.write(message)
|
||||
if ("method" in message) return
|
||||
const responded = responses.get(message.id)
|
||||
if (!responded) return
|
||||
responses.delete(message.id)
|
||||
Deferred.doneUnsafe(responded, "result" in message ? Effect.void : Effect.interrupt)
|
||||
},
|
||||
close: () => writer.close(),
|
||||
abort: (reason) => writer.abort(reason),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export type Connection = {
|
||||
readonly signal?: AbortSignal
|
||||
sessionUpdate(params: SessionNotification): Promise<void>
|
||||
requestPermission(params: RequestPermissionRequest, options?: SendRequestOptions): Promise<RequestPermissionResponse>
|
||||
extNotification?(method: string, params: Record<string, unknown>): Promise<void>
|
||||
}
|
||||
|
||||
/** Promise view for the turn and permission code until they run as effects. */
|
||||
export function make(connection: AgentConnection): Connection {
|
||||
return {
|
||||
signal: connection.signal,
|
||||
sessionUpdate: (params) => connection.client.notify(methods.client.session.update, params),
|
||||
requestPermission: (params, options) =>
|
||||
connection.client.request(methods.client.session.requestPermission, params, options),
|
||||
extNotification: (method, params) => connection.client.notify(method, params),
|
||||
agent,
|
||||
connection: Service.of({
|
||||
sessionUpdate: (params) => ACPPromise.promise(() => agent.client.notify(methods.client.session.update, params)),
|
||||
requestPermission: (params) =>
|
||||
ACPPromise.promise((signal) =>
|
||||
agent.client.request(methods.client.session.requestPermission, params, { cancellationSignal: signal }),
|
||||
),
|
||||
extNotification: (method, params) => ACPPromise.promise(() => agent.client.notify(method, params)),
|
||||
responded: (requestId) => {
|
||||
const responded = Deferred.makeUnsafe<void>()
|
||||
responses.set(requestId, responded)
|
||||
return Deferred.await(responded)
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { ClientError } from "@opencode/client/promise"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { ACPCatalog } from "./catalog"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
|
||||
sessionId: Schema.String,
|
||||
@@ -36,6 +34,11 @@ export class UnknownAuthMethodError extends Schema.TaggedError<UnknownAuthMethod
|
||||
methodId: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()("ACPInvalidRequestError", {
|
||||
message: Schema.String,
|
||||
field: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class ServiceFailureError extends Schema.TaggedError<ServiceFailureError>()("ACPServiceFailureError", {
|
||||
safeMessage: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
@@ -56,6 +59,7 @@ const Errors = Schema.Union([
|
||||
InvalidModeError,
|
||||
AuthRequiredError,
|
||||
UnknownAuthMethodError,
|
||||
InvalidRequestError,
|
||||
ServiceFailureError,
|
||||
ServerUnavailableError,
|
||||
])
|
||||
@@ -88,6 +92,8 @@ export function toRequestError(error: Error): RequestError {
|
||||
return RequestError.authRequired({}, "provider authentication required")
|
||||
case "ACPUnknownAuthMethodError":
|
||||
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
|
||||
case "ACPInvalidRequestError":
|
||||
return RequestError.invalidParams(error.field ? { field: error.field } : {}, error.message)
|
||||
case "ACPServiceFailureError":
|
||||
return RequestError.internalError(
|
||||
{
|
||||
@@ -103,18 +109,6 @@ export function toRequestError(error: Error): RequestError {
|
||||
return exhaustive
|
||||
}
|
||||
|
||||
/** Runs a promise, keeping ACP failures typed. Any other rejection is a defect. */
|
||||
export const promise = <A>(evaluate: (signal: AbortSignal) => Promise<A>) =>
|
||||
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(Effect.catch(classify))
|
||||
|
||||
export function classify(cause: unknown): Effect.Effect<never, Error | RequestError> {
|
||||
// A catalog load failure is classified by the client error that caused it.
|
||||
if (cause instanceof ACPCatalog.LoadError) return classify(cause.cause)
|
||||
if (cause instanceof RequestError || is(cause)) return Effect.fail(cause)
|
||||
if (cause instanceof ClientError && cause.reason === "Transport") return Effect.fail(new ServerUnavailableError())
|
||||
return Effect.die(cause)
|
||||
}
|
||||
|
||||
export function fromUnknown(error: unknown, service?: string) {
|
||||
const errorName = error instanceof Error ? error.name : undefined
|
||||
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
|
||||
|
||||
@@ -1,698 +0,0 @@
|
||||
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import { replyPermission } from "./permission"
|
||||
import {
|
||||
completedToolUpdate,
|
||||
errorToolUpdate,
|
||||
pendingToolCall,
|
||||
runningToolUpdate,
|
||||
type ToolContent,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
|
||||
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission">
|
||||
|
||||
export type TurnControl = {
|
||||
cancelled: boolean
|
||||
// Aborted whenever the turn is cancelled or closed, so it also cancels the turn's outbound client requests.
|
||||
readonly admission: AbortController
|
||||
}
|
||||
|
||||
type ToolState = {
|
||||
readonly name: string
|
||||
input: ToolInput
|
||||
metadata: Record<string, unknown>
|
||||
content: ToolContent
|
||||
}
|
||||
|
||||
export type TurnStart =
|
||||
| { readonly type: "input"; readonly id: string }
|
||||
| { readonly type: "skill"; readonly id: string }
|
||||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
const CompactionMeta = "opencode/compaction"
|
||||
|
||||
type RetryStatus = {
|
||||
readonly attempt: number
|
||||
readonly nextRetryAt: string
|
||||
readonly error: SessionStructuredError
|
||||
}
|
||||
|
||||
type CompactionMarker = {
|
||||
readonly status: "started" | "completed" | "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error?: SessionStructuredError
|
||||
}
|
||||
|
||||
type CompactionEvent = Extract<
|
||||
EventSubscribeOutput,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
type ChildSessionUpdateBase = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: 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 = ChildSessionUpdateBase & ChildSessionEvent
|
||||
|
||||
type ChildSession = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
function emptyToolState(): ToolState {
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
}
|
||||
|
||||
export async function streamTurn(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: Connection
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly action?: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
readonly connectionSignal?: AbortSignal
|
||||
readonly sessionSignal?: AbortSignal
|
||||
}): Promise<{ readonly response: PromptResponse; readonly contextTokens: number | undefined }> {
|
||||
const streamController = new AbortController()
|
||||
const connectionAbort = () => streamController.abort()
|
||||
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
||||
const connected = await stream.next()
|
||||
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
||||
|
||||
const control = input.control
|
||||
let started = false
|
||||
let finish: SessionMessageAssistant["finish"]
|
||||
let executionError: { readonly type: string; readonly message: string } | undefined
|
||||
let stepError: SessionStructuredError | undefined
|
||||
let usage: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo } | undefined
|
||||
const tools = new Map<string, ToolState>()
|
||||
const retries = new Map<string, RetryStatus>()
|
||||
const compactions = new Map<string, string>()
|
||||
const children = new Map<string, ChildSession>()
|
||||
const openChildren = new Set<string>()
|
||||
let handedOff = false
|
||||
|
||||
const recordStep = (tokens: TokenUsageInfo) => {
|
||||
const turn = usage?.turn
|
||||
usage = {
|
||||
turn: turn
|
||||
? {
|
||||
input: turn.input + tokens.input,
|
||||
output: turn.output + tokens.output,
|
||||
reasoning: turn.reasoning + tokens.reasoning,
|
||||
cache: { read: turn.cache.read + tokens.cache.read, write: turn.cache.write + tokens.cache.write },
|
||||
}
|
||||
: tokens,
|
||||
last: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
|
||||
if (!input.childSessionUpdate) return
|
||||
await input
|
||||
.childSessionUpdate({
|
||||
rootSessionId: input.sessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...value,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
|
||||
const projected = child ? projectChildUpdate(value, child) : value
|
||||
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
|
||||
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
|
||||
}
|
||||
if (child) await notifyChild(child, { type: "update", update: projected })
|
||||
}
|
||||
|
||||
const consume = async (mode: "turn" | "background") => {
|
||||
while (!streamController.signal.aborted) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) continue
|
||||
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
|
||||
if ((mode === "turn" && parentID === input.sessionID) || parent) {
|
||||
const child = {
|
||||
id: event.data.sessionID,
|
||||
parentID,
|
||||
depth: parent ? parent.depth + 1 : 1,
|
||||
title: event.data.title,
|
||||
}
|
||||
children.set(child.id, child)
|
||||
openChildren.add(child.id)
|
||||
await notifyChild(child, { type: "status", status: "created" })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const eventSessionID = sessionIDFromEvent(event)
|
||||
const child = eventSessionID ? children.get(eventSessionID) : undefined
|
||||
const send = (update: SessionUpdate) => updateSession(update, child, mode)
|
||||
if (mode === "background" && !child) continue
|
||||
|
||||
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
|
||||
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
|
||||
await replyPermission({
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event,
|
||||
sessionID: event.data.sessionID,
|
||||
clientSessionID: input.sessionID,
|
||||
cwd: input.cwd,
|
||||
tool,
|
||||
signal: control.admission.signal,
|
||||
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
|
||||
await input.client.session.form
|
||||
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
|
||||
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
|
||||
continue
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
|
||||
if (matchesStart(event, input.start)) {
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (!started) continue
|
||||
|
||||
if (event.type === "session.execution.started") {
|
||||
if (child) {
|
||||
await notifyChild(child, { type: "status", status: "running" })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
if (!child) stepError = undefined
|
||||
if (retries.delete(eventSessionID))
|
||||
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.retry.scheduled") {
|
||||
const retry = {
|
||||
attempt: event.data.attempt,
|
||||
nextRetryAt: new Date(event.data.at).toISOString(),
|
||||
error: event.data.error,
|
||||
}
|
||||
retries.set(eventSessionID, retry)
|
||||
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } })
|
||||
continue
|
||||
}
|
||||
if (
|
||||
event.type === "session.compaction.started" ||
|
||||
event.type === "session.compaction.ended" ||
|
||||
event.type === "session.compaction.failed"
|
||||
) {
|
||||
const marker = compactionMarker(event, compactions)
|
||||
if (marker) await send(compactionUpdate(marker))
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
await send({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
await send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
tools.set(toolKey(event.data.sessionID, event.data.id), {
|
||||
name: event.data.name,
|
||||
input: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
await send({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: event.data.id,
|
||||
toolName: event.data.name,
|
||||
state: { input: {} },
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
current.input = event.data.input
|
||||
tools.set(key, current)
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(toolKey(event.data.sessionID, event.data.id))
|
||||
if (!current) continue
|
||||
current.metadata = event.data.metadata
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata ?? current.metadata,
|
||||
content: event.data.content ?? current.content,
|
||||
error: event.data.error.message,
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
if (!child) {
|
||||
finish = event.data.finish
|
||||
recordStep(event.data.tokens)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
if (!child) {
|
||||
stepError = event.data.error
|
||||
if (event.data.tokens) recordStep(event.data.tokens)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.succeeded") {
|
||||
if (!child) return "succeeded" as const
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "completed" })
|
||||
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
if (!child) return "interrupted" as const
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "interrupted" })
|
||||
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (child) {
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
|
||||
if (mode === "background" && openChildren.size === 0) return "failed" as const
|
||||
continue
|
||||
}
|
||||
executionError = event.data.error
|
||||
return "failed" as const
|
||||
}
|
||||
}
|
||||
return "interrupted" as const
|
||||
}
|
||||
|
||||
const completed = consume("turn")
|
||||
const closeStream = async () => {
|
||||
streamController.abort()
|
||||
input.connectionSignal?.removeEventListener("abort", connectionAbort)
|
||||
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
try {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
})
|
||||
if (input.action) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return {
|
||||
response: response(undefined, undefined, "succeeded", control.cancelled, undefined),
|
||||
contextTokens: undefined,
|
||||
}
|
||||
}
|
||||
if (control.cancelled) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
if (!started) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return { response: response(undefined, undefined, "interrupted", true, undefined), contextTokens: undefined }
|
||||
}
|
||||
}
|
||||
const terminal = await completed
|
||||
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
|
||||
handedOff = true
|
||||
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
void consume("background")
|
||||
.catch(() => {})
|
||||
.finally(closeStream)
|
||||
}
|
||||
return {
|
||||
response: response(
|
||||
usage?.turn,
|
||||
stepError ?? executionError,
|
||||
terminal,
|
||||
control.cancelled,
|
||||
finish,
|
||||
retries.get(input.sessionID),
|
||||
),
|
||||
contextTokens: usage ? TokenUsage.total(usage.last) : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
if (!handedOff) await closeStream()
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||
if (event.type === "form.created") return event.data.form.sessionID
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
|
||||
function compactionMarker(event: CompactionEvent, compactions: Map<string, string>): CompactionMarker | undefined {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
|
||||
compactions.set(sessionID, messageId)
|
||||
return { status: "started", messageId, reason: event.data.reason }
|
||||
}
|
||||
const tracked = compactions.get(sessionID)
|
||||
compactions.delete(sessionID)
|
||||
if (event.type === "session.compaction.ended")
|
||||
return tracked ? { status: "completed", messageId: tracked, reason: event.data.reason } : undefined
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
|
||||
return {
|
||||
status: "failed",
|
||||
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
}
|
||||
|
||||
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
|
||||
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
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 async function replayMessages(
|
||||
connection: Pick<Connection, "sessionUpdate">,
|
||||
sessionID: string,
|
||||
cwd: string,
|
||||
messages: readonly SessionMessageInfo[],
|
||||
) {
|
||||
for (const message of messages) await replayMessage(connection, sessionID, cwd, message).catch(() => {})
|
||||
}
|
||||
|
||||
async function replayMessage(
|
||||
connection: Pick<Connection, "sessionUpdate">,
|
||||
sessionID: string,
|
||||
cwd: string,
|
||||
message: SessionMessageInfo,
|
||||
) {
|
||||
if (message.type === "user") {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
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)) {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
|
||||
if (message.type === "compaction" && message.status !== "running") {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: compactionUpdate({
|
||||
status: message.status,
|
||||
messageId: message.id,
|
||||
reason: message.reason,
|
||||
...(message.status === "failed" ? { error: message.error } : {}),
|
||||
}),
|
||||
})
|
||||
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") {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: message.id,
|
||||
content: { type: "text", text: part.text },
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
|
||||
content: { type: "text", text: part.text },
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
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":
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
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":
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.input },
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
break
|
||||
case "error":
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
|
||||
if (start.type === "input") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
if (start.type === "compaction") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
|
||||
}
|
||||
|
||||
function response(
|
||||
tokens: TokenUsageInfo | undefined,
|
||||
error: { readonly type: string; readonly message: string } | undefined,
|
||||
terminal: "succeeded" | "failed" | "interrupted",
|
||||
cancelled: boolean,
|
||||
finish: SessionMessageAssistant["finish"],
|
||||
retry?: RetryStatus,
|
||||
): PromptResponse {
|
||||
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
throw new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: TokenUsage.total(tokens),
|
||||
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
|
||||
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
|
||||
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
|
||||
}
|
||||
: undefined
|
||||
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
|
||||
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
readonly terminal: "succeeded" | "failed" | "interrupted"
|
||||
readonly cancelled: boolean
|
||||
readonly finish: SessionMessageAssistant["finish"]
|
||||
readonly error?: string
|
||||
}): PromptResponse["stopReason"] {
|
||||
if (input.cancelled || input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
|
||||
if (input.finish === "length") return "max_tokens"
|
||||
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
|
||||
return "end_turn"
|
||||
}
|
||||
|
||||
export * as ACPEvent from "./event"
|
||||
@@ -1,53 +1,62 @@
|
||||
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode/client/promise"
|
||||
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { Cause, Effect } from "effect"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import {
|
||||
absolutePath,
|
||||
filePath,
|
||||
patchHunks,
|
||||
pendingToolCall,
|
||||
stringValue,
|
||||
toLocations,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type Connection = Pick<ACPConnection.Connection, "requestPermission">
|
||||
type Tool = { readonly name: string; readonly input: ToolInput }
|
||||
|
||||
type Input = {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: string
|
||||
readonly clientSessionID: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
export async function replyPermission(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: Connection
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: string
|
||||
readonly clientSessionID?: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
readonly signal?: AbortSignal
|
||||
}) {
|
||||
/**
|
||||
* Asks the client, then replies to the server. Once `cancelled` completes, the client's request is cancelled or never
|
||||
* sent, and the server gets `reject`. The server reply is uninterruptible, so a server that is alive but stuck can
|
||||
* hold a cancel past `CancelDrainTimeout`; a dead server fails fast.
|
||||
*/
|
||||
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(
|
||||
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)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||
const title = permissionTitle(toolName, toolInput, previews)
|
||||
const request = {
|
||||
sessionId: input.clientSessionID ?? input.sessionID,
|
||||
const result = yield* input.connection.requestPermission({
|
||||
sessionId: input.clientSessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.titlePrefix, title),
|
||||
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
@@ -55,18 +64,15 @@ export async function replyPermission(input: {
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
},
|
||||
options,
|
||||
}
|
||||
// An already-cancelled turn skips the round-trip; the SDK would still send the request and then cancel it.
|
||||
const result = input.signal?.aborted
|
||||
? undefined
|
||||
: await input.connection.requestPermission(request, { cancellationSignal: input.signal }).catch(() => undefined)
|
||||
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
||||
const reply = selected === "once" || selected === "always" ? selected : "reject"
|
||||
await input.client.permission.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.event.data.id,
|
||||
decision: reply,
|
||||
})
|
||||
const selected = result.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
||||
return selected === "once" || selected === "always" ? selected : "reject"
|
||||
})
|
||||
|
||||
function respond(input: Input, decision: PermissionReplyInput["decision"]) {
|
||||
return ACPPromise.promise(() =>
|
||||
input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }),
|
||||
)
|
||||
}
|
||||
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
@@ -75,45 +81,50 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
||||
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
const file = filePath(input)
|
||||
if (!file) return []
|
||||
const path = absolutePath(file, cwd)
|
||||
const oldText = await readText(path)
|
||||
if (tool === "write") {
|
||||
const content = stringValue(input.content)
|
||||
return content === undefined ? [] : [{ type: "diff", path, oldText, newText: content }]
|
||||
if (content === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
return [diff(path, oldText, content)]
|
||||
}
|
||||
if (tool !== "edit") return []
|
||||
const oldString = stringValue(input.oldString)
|
||||
const newString = stringValue(input.newString)
|
||||
if (oldString === undefined || newString === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
const newText =
|
||||
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
|
||||
return [{ type: "diff", path, oldText, newText }]
|
||||
return [diff(path, oldText, newText)]
|
||||
})
|
||||
|
||||
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
|
||||
function patchPreviews(input: ToolInput, cwd: string) {
|
||||
return Effect.forEach(
|
||||
patchHunks(input),
|
||||
(hunk) =>
|
||||
Effect.gen(function* () {
|
||||
const path = absolutePath(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return diff(path, "", newText)
|
||||
}
|
||||
const oldText = yield* readText(path)
|
||||
if (hunk.type === "delete") return diff(path, oldText, "")
|
||||
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
|
||||
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
|
||||
}
|
||||
|
||||
function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
||||
// Patch.derive throws when a hunk does not match the current file.
|
||||
return Promise.all(
|
||||
patchHunks(input).map(async (hunk): Promise<ToolCallContent> => {
|
||||
const path = absolutePath(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return { type: "diff", path, oldText: "", newText }
|
||||
}
|
||||
const oldText = await readText(path)
|
||||
if (hunk.type === "delete") return { type: "diff", path, oldText, newText: "" }
|
||||
return {
|
||||
type: "diff",
|
||||
path: hunk.movePath ? absolutePath(hunk.movePath, cwd) : path,
|
||||
oldText,
|
||||
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
|
||||
}
|
||||
}),
|
||||
).catch(() => [])
|
||||
function diff(path: string, oldText: string, newText: string): ToolCallContent {
|
||||
return { type: "diff", path, oldText, newText }
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
|
||||
@@ -150,10 +161,9 @@ function permissionLocations(
|
||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
|
||||
}
|
||||
|
||||
// A missing file previews as empty.
|
||||
function readText(path: string) {
|
||||
return Bun.file(path)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
|
||||
}
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { ClientError, isInvalidRequestError, isSessionNotFoundError } from "@opencode/client/promise"
|
||||
import { Effect } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
/** Runs a promise, keeping ACP failures typed. Any other rejection is a defect. */
|
||||
export const promise = <A>(evaluate: (signal: AbortSignal) => Promise<A>) =>
|
||||
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(Effect.catch(classify))
|
||||
|
||||
export function classify(cause: unknown): Effect.Effect<never, ACPError.Error | RequestError> {
|
||||
if (cause instanceof RequestError || ACPError.is(cause)) return Effect.fail(cause)
|
||||
if (cause instanceof ClientError && cause.reason === "Transport")
|
||||
return Effect.fail(new ACPError.ServerUnavailableError())
|
||||
if (isSessionNotFoundError(cause))
|
||||
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: cause.sessionID }))
|
||||
if (isInvalidRequestError(cause))
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: cause.message, field: cause.field }))
|
||||
return Effect.die(cause)
|
||||
}
|
||||
|
||||
export * as ACPPromise from "./promise"
|
||||
+98
-245
@@ -1,9 +1,14 @@
|
||||
import { isSessionNotFoundError, type CommandInfo, type ModelRef, type OpenCodeClient } from "@opencode/client/promise"
|
||||
import {
|
||||
isInvalidRequestError,
|
||||
isSessionNotFoundError,
|
||||
type ModelRef,
|
||||
type OpenCodeClient,
|
||||
type SessionMessageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Effect, Option, Ref, Stream, type Scope } from "effect"
|
||||
import { Effect, Option, Ref, Result, Stream } from "effect"
|
||||
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
|
||||
import type {
|
||||
AgentConnection,
|
||||
AuthenticateRequest,
|
||||
AuthenticateResponse,
|
||||
AuthMethod,
|
||||
@@ -33,34 +38,17 @@ import type {
|
||||
SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { promptContentToParts } from "./content"
|
||||
import {
|
||||
ChildSessionUpdateMethod,
|
||||
ChildSessionUpdatesCapability,
|
||||
replayMessages,
|
||||
streamTurn,
|
||||
type ChildSessionUpdate,
|
||||
type TurnControl,
|
||||
type TurnStart,
|
||||
} from "./event"
|
||||
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPSessions, builtinCommands, type Attached } from "./sessions"
|
||||
import { ACPPromise } from "./promise"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import type { ACPTurn } from "./turn"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
type PreparedPrompt = {
|
||||
readonly start: TurnStart
|
||||
readonly text: string
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: CommandInfo
|
||||
}
|
||||
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export interface Interface {
|
||||
@@ -71,32 +59,26 @@ export interface Interface {
|
||||
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 setSessionConfigOption: (
|
||||
input: SetSessionConfigOptionRequest,
|
||||
) => Effect.Effect<SetSessionConfigOptionResponse, Failure>
|
||||
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Failure>
|
||||
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
|
||||
prompt(input: PromptRequest, signal?: AbortSignal): Promise<PromptResponse>
|
||||
cancel(input: CancelNotification): Promise<void>
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
export function make(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: AgentConnection
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
readonly run: <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) => Promise<A>
|
||||
}) {
|
||||
const connection = ACPConnection.service(input.connection)
|
||||
// The turn still runs on promises.
|
||||
const turnConnection = ACPConnection.make(input.connection)
|
||||
const sessions = yield* ACPSessions.make({ client: input.client, connection, catalog: input.catalog })
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false })
|
||||
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
|
||||
|
||||
const configOptions = Effect.fnUntraced(function* (attached: Attached) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
return ACPSessions.configOptions(catalog, yield* Ref.get(attached.selection))
|
||||
readonly sessions: ACPSessions.Interface
|
||||
readonly capabilities: Ref.Ref<{ readonly childSessionUpdates: boolean }>
|
||||
readonly turn: ACPTurn.Interface
|
||||
}): Interface {
|
||||
const currentOptions = Effect.fnUntraced(function* (attached: Attached) {
|
||||
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
|
||||
})
|
||||
|
||||
// A selection the catalog has not seen may be new on the server, so reload once before rejecting it.
|
||||
@@ -108,81 +90,65 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
}
|
||||
|
||||
const selectOption = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
|
||||
if (configId === "mode") return yield* selectMode(attached, value)
|
||||
if (configId !== "model" && configId !== "effort") return yield* new ACPError.InvalidConfigOptionError({ configId })
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = ACPSessions.currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model =
|
||||
configId === "model"
|
||||
? yield* requireModel(catalog, value, current)
|
||||
: yield* requireEffort(catalog, value, current)
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
switch (configId) {
|
||||
case "model":
|
||||
return yield* selectModel(attached, yield* requireModel(catalog, value, current))
|
||||
case "effort":
|
||||
return yield* selectModel(attached, yield* requireEffort(catalog, value, current))
|
||||
case "mode":
|
||||
return yield* selectMode(attached, value)
|
||||
default:
|
||||
return yield* new ACPError.InvalidConfigOptionError({ configId })
|
||||
}
|
||||
})
|
||||
|
||||
// Both selectors update the selection before switching on the server, so the echoed event diffs to no change.
|
||||
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: ModelRef) {
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
|
||||
yield* ACPError.promise(() => input.client.session.switchModel({ sessionID: attached.id, model }))
|
||||
yield* ACPPromise.promise(() => input.client.session.switchModel({ sessionID: attached.id, model }))
|
||||
})
|
||||
|
||||
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
if (!catalog.modes.some((mode) => mode.id === modeID)) return yield* new ACPError.InvalidModeError({ mode: modeID })
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID }))
|
||||
yield* ACPError.promise(() => input.client.session.switchAgent({ sessionID: attached.id, agent: modeID }))
|
||||
yield* ACPPromise.promise(() => input.client.session.switchAgent({ sessionID: attached.id, agent: modeID }))
|
||||
})
|
||||
|
||||
const getSession = Effect.fnUntraced(function* (sessionID: string, cwd: string) {
|
||||
const session = yield* ACPError.promise(() => input.client.session.get({ sessionID }).catch(notFound(sessionID)))
|
||||
const session = yield* ACPPromise.promise(() => input.client.session.get({ sessionID }))
|
||||
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory))
|
||||
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
|
||||
return session
|
||||
})
|
||||
|
||||
const replay = (attached: Attached) =>
|
||||
Stream.paginate(Option.none<string>(), (cursor) =>
|
||||
ACPError.promise(() =>
|
||||
Option.isSome(cursor)
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor: cursor.value })
|
||||
Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
ACPPromise.promise(() =>
|
||||
cursor
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" }),
|
||||
).pipe(
|
||||
Effect.map(
|
||||
(page) => [page.data, Option.fromNullishOr(page.cursor.next).pipe(Option.map(Option.some))] as const,
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.flatMap((messages) =>
|
||||
ACPError.promise(() => replayMessages(turnConnection, attached.id, attached.cwd, messages)),
|
||||
),
|
||||
)
|
||||
).pipe(Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const)),
|
||||
).pipe(Stream.runForEach((message) => replayMessage(attached, message)))
|
||||
|
||||
const sendUsageUpdate = Effect.fnUntraced(function* (attached: Attached, used: number | undefined) {
|
||||
if (!used) return
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = ACPSessions.currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model?.limit.context) return
|
||||
const info = yield* ACPError.promise(() => input.client.session.get({ sessionID: attached.id }))
|
||||
yield* connection.sessionUpdate({
|
||||
sessionId: attached.id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used,
|
||||
size: model.limit.context,
|
||||
cost: { amount: info.cost, currency: "USD" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const cancelTurn = (sessionID: string) => {
|
||||
const turn = active.get(sessionID)
|
||||
if (turn) {
|
||||
turn.control.cancelled = true
|
||||
turn.control.admission.abort()
|
||||
// A message that fails to translate keeps the updates before the failure and does not stop the replay.
|
||||
const replayMessage = Effect.fnUntraced(function* (attached: Attached, message: SessionMessageInfo) {
|
||||
const updates = ACPTranslate.replayMessage(message, attached.cwd)
|
||||
while (true) {
|
||||
const next = yield* Effect.result(Effect.try(() => updates.next()))
|
||||
if (Result.isFailure(next))
|
||||
return yield* Effect.logWarning("ACP replay skipped the rest of a message", message.id, next.failure.cause)
|
||||
if (next.success.done) return
|
||||
yield* input.connection.sessionUpdate({ sessionId: attached.id, update: next.success.value })
|
||||
}
|
||||
return input.client.session.interrupt({ sessionID })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
initialize: Effect.fnUntraced(function* (params) {
|
||||
yield* Ref.set(capabilities, {
|
||||
childSessionUpdates: params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true,
|
||||
yield* Ref.set(input.capabilities, {
|
||||
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
|
||||
})
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
@@ -201,7 +167,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ChildSessionUpdatesCapability]: true },
|
||||
_meta: { [ACPTranslate.ChildSessionUpdatesCapability]: true },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
@@ -216,20 +182,23 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
// Load before creating so a catalog failure leaves no session behind. Agent and model stay unset
|
||||
// so the server resolves its defaults after plugins activate.
|
||||
yield* input.catalog.get(params.cwd)
|
||||
const created = yield* ACPError.promise(() =>
|
||||
const created = yield* ACPPromise.promise(() =>
|
||||
input.client.session.create({ location: { directory: params.cwd } }),
|
||||
)
|
||||
const attached = yield* sessions.attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: attached.id, configOptions: yield* configOptions(attached) }
|
||||
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: attached.id, configOptions: yield* currentOptions(attached) }
|
||||
}),
|
||||
loadSession: Effect.fnUntraced(function* (params) {
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
const attached = yield* sessions.attach(session, session.location.directory, params.mcpServers)
|
||||
yield* replay(attached)
|
||||
return { configOptions: yield* configOptions(attached) }
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers)
|
||||
return yield* replay(attached).pipe(
|
||||
Effect.andThen(currentOptions(attached)),
|
||||
Effect.map((configOptions) => ({ configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
}),
|
||||
listSessions: Effect.fnUntraced(function* (params) {
|
||||
const page = yield* ACPError.promise(() =>
|
||||
const page = yield* ACPPromise.promise(() =>
|
||||
input.client.session.list({
|
||||
...(params.cwd ? { directory: params.cwd } : {}),
|
||||
order: "desc",
|
||||
@@ -248,159 +217,51 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
}
|
||||
}),
|
||||
deleteSession: Effect.fnUntraced(function* (params) {
|
||||
yield* ACPError.promise(() =>
|
||||
input.client.session.remove({ sessionID: params.sessionId }).catch(notFound(params.sessionId)),
|
||||
).pipe(Effect.catchTag("ACPSessionNotFoundError", () => Effect.void))
|
||||
yield* sessions.detach(params.sessionId)
|
||||
// A malformed ID fails the server's path decode, and the session ID is the only path param.
|
||||
yield* ACPPromise.promise(() =>
|
||||
input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (isSessionNotFoundError(error) || (isInvalidRequestError(error) && error.kind === "Params")) return
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
yield* input.sessions.detach(params.sessionId)
|
||||
return {}
|
||||
}),
|
||||
resumeSession: Effect.fnUntraced(function* (params) {
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
const attached = yield* sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: yield* configOptions(attached) }
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
}),
|
||||
closeSession: Effect.fnUntraced(function* (params) {
|
||||
yield* input.turn.close(params.sessionId)
|
||||
yield* input.sessions.detach(params.sessionId)
|
||||
return {}
|
||||
}),
|
||||
forkSession: Effect.fnUntraced(function* (params) {
|
||||
const forked = yield* ACPError.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
|
||||
const attached = yield* sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
yield* replay(attached)
|
||||
return { sessionId: attached.id, configOptions: yield* configOptions(attached) }
|
||||
const forked = yield* ACPPromise.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
|
||||
const attached = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
return yield* currentOptions(attached).pipe(
|
||||
Effect.map((configOptions) => ({ sessionId: attached.id, configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
}),
|
||||
setSessionConfigOption: Effect.fnUntraced(function* (params) {
|
||||
const attached = yield* sessions.require(params.sessionId)
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const value = params.value
|
||||
if (typeof value !== "string") return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
yield* withReload(attached, selectOption(attached, params.configId, value))
|
||||
return { configOptions: yield* configOptions(attached) }
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
}),
|
||||
setSessionMode: Effect.fnUntraced(function* (params) {
|
||||
const attached = yield* sessions.require(params.sessionId)
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
yield* withReload(attached, selectMode(attached, params.modeId))
|
||||
return {}
|
||||
}),
|
||||
closeSession: async (params) => {
|
||||
const turn = active.get(params.sessionId)
|
||||
await cancelTurn(params.sessionId).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
await turn?.turn.catch(() => {})
|
||||
await input.run(sessions.detach(params.sessionId))
|
||||
return {}
|
||||
},
|
||||
prompt: async (params, signal) => {
|
||||
// Read everything first so the active check and registration below stay synchronous.
|
||||
const resolved = await input.run(
|
||||
Effect.gen(function* () {
|
||||
const attached = yield* sessions.require(params.sessionId)
|
||||
return {
|
||||
attached,
|
||||
catalog: yield* input.catalog.get(attached.cwd),
|
||||
childSessionUpdates: (yield* Ref.get(capabilities)).childSessionUpdates,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const state = resolved.attached
|
||||
if (active.has(state.id)) {
|
||||
throw new ACPError.ServiceFailureError({
|
||||
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
|
||||
service: "session",
|
||||
})
|
||||
}
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prepared = preparePrompt(resolved.catalog, params.prompt, messageID)
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const extNotification = turnConnection.extNotification
|
||||
const childSessionUpdate =
|
||||
resolved.childSessionUpdates && extNotification
|
||||
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
|
||||
: undefined
|
||||
// A `$/cancel_request` for this prompt behaves like `session/cancel` for its turn.
|
||||
const cancel = () => void cancelTurn(state.id).catch(() => {})
|
||||
const turn = streamTurn({
|
||||
client: input.client,
|
||||
connection: turnConnection,
|
||||
sessionID: state.id,
|
||||
cwd: state.cwd,
|
||||
start: prepared.start,
|
||||
action: prepared.command !== undefined,
|
||||
control,
|
||||
connectionSignal: turnConnection.signal,
|
||||
sessionSignal: state.signal,
|
||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||
...(childSessionUpdate ? { childSessionUpdate } : {}),
|
||||
})
|
||||
.then(async (result) => {
|
||||
await input.run(sendUsageUpdate(state, result.contextTokens)).catch(() => {})
|
||||
return result.response
|
||||
})
|
||||
.finally(() => {
|
||||
signal?.removeEventListener("abort", cancel)
|
||||
if (active.get(state.id)?.control === control) active.delete(state.id)
|
||||
})
|
||||
active.set(state.id, { control, turn })
|
||||
signal?.addEventListener("abort", cancel, { once: true })
|
||||
// The cancel may already be buffered behind the awaits above.
|
||||
if (signal?.aborted) cancel()
|
||||
return turn
|
||||
},
|
||||
cancel: async (params) => {
|
||||
await cancelTurn(params.sessionId).catch(() => {})
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
function notFound(sessionID: string) {
|
||||
return (error: unknown): never => {
|
||||
throw isSessionNotFoundError(error) ? new ACPError.SessionNotFoundError({ sessionId: sessionID }) : error
|
||||
prompt: input.turn.prompt,
|
||||
cancel: input.turn.cancel,
|
||||
}
|
||||
}
|
||||
|
||||
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
|
||||
const parts = promptContentToParts(prompt)
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
|
||||
const slash = detectSlashCommand(text)
|
||||
const command =
|
||||
slash && !builtinCommands.has(slash.name) ? catalog.commands.find((item) => item.name === slash.name) : undefined
|
||||
const start = turnStart(messageID, slash)
|
||||
return { start, text, files, synthetic, slash, command }
|
||||
}
|
||||
|
||||
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
|
||||
if (prompt.synthetic.length > 0) {
|
||||
await client.session.synthetic({
|
||||
sessionID: session.id,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
})
|
||||
}
|
||||
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
|
||||
if (prompt.command) {
|
||||
return client.session.command(
|
||||
{
|
||||
sessionID: session.id,
|
||||
name: prompt.command.name,
|
||||
text: prompt.slash?.args ?? "",
|
||||
files: prompt.files,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
return client.session.prompt(
|
||||
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
|
||||
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: ModelRef) {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
@@ -426,12 +287,4 @@ const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: str
|
||||
return { ...current, variant: effort } satisfies ModelRef
|
||||
})
|
||||
|
||||
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
if (!name) return undefined
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
export * as ACPService from "./service"
|
||||
@@ -1,35 +1,24 @@
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { McpServer, RequestError } from "@agentclientprotocol/sdk"
|
||||
import type { ModelRef, OpenCodeClient, SessionInfo } from "@opencode/client/promise"
|
||||
import { Cause, Context, Effect, Exit, Ref, Scope, Stream } from "effect"
|
||||
import type { OpenCodeClient, OpenCodeEvent, SessionInfo } from "@opencode/client/promise"
|
||||
import { Context, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { buildConfigOptions } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { availableCommands, configOptions, type Selection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
// ACP runs these itself; they take precedence over server commands with the same name.
|
||||
export const builtinCommands = new Map([
|
||||
["compact", { description: "Compact the session", start: "compaction" as const }],
|
||||
])
|
||||
|
||||
/** Unset fields follow the server defaults. */
|
||||
export type Selection = {
|
||||
readonly model?: ModelRef
|
||||
readonly modeID?: string
|
||||
}
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
export type Attached = {
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly selection: Ref.Ref<Selection>
|
||||
/** Aborted when the session detaches, for the promise-based turn. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Attaches a session in its own scope, closing any previous attachment of the same ID. The scope follows the
|
||||
* cwd's catalog and pushes config option and command updates while it is open.
|
||||
* Attaches a session in its own scope, closing any previous attachment of the same ID. Once the attaching request
|
||||
* has responded, the scope follows the cwd's catalog and pushes config option and command updates while it is
|
||||
* open. A failed attach leaves the session detached.
|
||||
*/
|
||||
readonly attach: (
|
||||
session: SessionInfo,
|
||||
@@ -39,37 +28,61 @@ export interface Interface {
|
||||
/** Closes the session scope. No-op when the session is not attached. */
|
||||
readonly detach: (sessionID: string) => Effect.Effect<void>
|
||||
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
|
||||
/** Forks work into this attachment's scope, so it ends on detach or re-attach. Fails once the attachment is gone. */
|
||||
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
|
||||
/** Selection changes from other clients, applied by the session's fold. */
|
||||
readonly selected: Queue.Queue<Selection>
|
||||
}
|
||||
|
||||
type SelectedEvent = Extract<OpenCodeEvent, { type: "session.model.selected" | "session.agent.selected" }>
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
}) {
|
||||
const scope = yield* Effect.scope
|
||||
const sessions = new Map<string, { readonly attached: Attached; readonly scope: Scope.Closeable }>()
|
||||
const sessions = new Map<string, Entry>()
|
||||
// Kept across re-attachment so resuming with the same servers does not add them again.
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
// Subscribe before any attach so a switch right after `sessions.set` reaches the session.
|
||||
yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.tap((event) => (event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void)),
|
||||
Stream.filter(
|
||||
(event): event is SelectedEvent =>
|
||||
event.type === "session.model.selected" || event.type === "session.agent.selected",
|
||||
),
|
||||
Stream.runForEach((event) => {
|
||||
const entry = sessions.get(event.data.sessionID)
|
||||
if (!entry) return Effect.void
|
||||
return Queue.offer(
|
||||
entry.selected,
|
||||
event.type === "session.model.selected" ? { model: event.data.model } : { modeID: event.data.agent },
|
||||
)
|
||||
}),
|
||||
Effect.ignore,
|
||||
Effect.ensuring(Deferred.succeed(connected, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const sendCommands = (sessionID: string, catalog: Catalog) =>
|
||||
input.connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
...catalog.commands
|
||||
.filter((command) => !builtinCommands.has(command.name))
|
||||
.map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
...Array.from(builtinCommands, ([name, command]) => ({ name, description: command.description })),
|
||||
],
|
||||
},
|
||||
update: { sessionUpdate: "available_commands_update", availableCommands: availableCommands(catalog) },
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (attached: Attached, previous: Catalog, next: Catalog) {
|
||||
const selection = yield* Ref.get(attached.selection)
|
||||
const options = configOptions(next, selection)
|
||||
const changed = Effect.fnUntraced(function* (attached: Attached, previous: Catalog, next: Catalog, patch: Selection) {
|
||||
const selection = yield* Ref.getAndUpdate(attached.selection, (current) => ({ ...current, ...patch }))
|
||||
const options = configOptions(next, { ...selection, ...patch })
|
||||
if (!isDeepStrictEqual(options, configOptions(previous, selection))) {
|
||||
yield* input.connection.sessionUpdate({
|
||||
sessionId: attached.id,
|
||||
@@ -91,82 +104,83 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const key = `${server.name}:${stableStringify(config)}`
|
||||
if (registered.has(key)) return Effect.void
|
||||
registered.add(key)
|
||||
return ACPError.promise(() =>
|
||||
return ACPPromise.promise(() =>
|
||||
input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }),
|
||||
).pipe(
|
||||
// An interrupted add still completes on the server, so only a failed one is forgotten.
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.sync(() => registered.delete(key)),
|
||||
),
|
||||
Effect.onError(() => Effect.sync(() => registered.delete(key))),
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
}),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const remove = (sessionID: string, entry: Entry) =>
|
||||
Effect.suspend(() => {
|
||||
if (sessions.get(sessionID) === entry) {
|
||||
sessions.delete(sessionID)
|
||||
registeredMcp.delete(sessionID)
|
||||
}
|
||||
return Scope.close(entry.scope, Exit.void)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
attach: Effect.fn("cli.acp.sessions.attach")(function* (session, cwd, mcpServers) {
|
||||
yield* Deferred.await(connected)
|
||||
const current = yield* input.catalog.get(cwd)
|
||||
const abort = new AbortController()
|
||||
const attached: Attached = {
|
||||
id: session.id,
|
||||
cwd,
|
||||
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
|
||||
signal: abort.signal,
|
||||
const entry: Entry = {
|
||||
attached: {
|
||||
id: session.id,
|
||||
cwd,
|
||||
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
|
||||
},
|
||||
scope: Scope.forkUnsafe(scope),
|
||||
selected: yield* Queue.unbounded<Selection>(),
|
||||
}
|
||||
// Swap synchronously so concurrent attaches of one ID cannot both keep a scope.
|
||||
const sessionScope = Scope.forkUnsafe(scope)
|
||||
const replaced = sessions.get(session.id)
|
||||
sessions.set(session.id, { attached, scope: sessionScope })
|
||||
sessions.set(session.id, entry)
|
||||
if (replaced) yield* Scope.close(replaced.scope, Exit.void)
|
||||
yield* Scope.addFinalizer(
|
||||
sessionScope,
|
||||
Effect.sync(() => abort.abort()),
|
||||
)
|
||||
yield* input.catalog.changes(cwd).pipe(
|
||||
Stream.runFoldEffect(
|
||||
() => current,
|
||||
(previous, next) =>
|
||||
next === previous
|
||||
? Effect.succeed(previous)
|
||||
: changed(attached, previous, next).pipe(Effect.ignore, Effect.as(next)),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(sessionScope),
|
||||
)
|
||||
yield* registerMcp(attached, mcpServers)
|
||||
yield* sendCommands(attached.id, yield* input.catalog.get(cwd))
|
||||
return attached
|
||||
yield* registerMcp(entry.attached, mcpServers).pipe(Effect.onError(() => remove(session.id, entry)))
|
||||
const responded = yield* ACPConnection.Responded
|
||||
// Updates wait for the response that hands the client this session. `changes` emits the latest catalog
|
||||
// first, so a reload since `current` is still pushed. One fold applies catalog and selection changes so
|
||||
// pushes leave the client on the latest pair.
|
||||
yield* Effect.gen(function* () {
|
||||
yield* responded
|
||||
yield* sendCommands(session.id, current)
|
||||
yield* Stream.merge(
|
||||
input.catalog.changes(cwd).pipe(Stream.map((catalog) => ({ catalog, patch: {} }))),
|
||||
Stream.fromQueue(entry.selected).pipe(Stream.map((patch) => ({ catalog: undefined, patch }))),
|
||||
).pipe(
|
||||
Stream.runFoldEffect(
|
||||
() => current,
|
||||
(previous, step) => {
|
||||
const next = step.catalog ?? previous
|
||||
return changed(entry.attached, previous, next, step.patch).pipe(Effect.ignore, Effect.as(next))
|
||||
},
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.ignore, Effect.forkIn(entry.scope))
|
||||
return entry.attached
|
||||
}),
|
||||
detach: Effect.fn("cli.acp.sessions.detach")(function* (sessionID) {
|
||||
const entry = sessions.get(sessionID)
|
||||
sessions.delete(sessionID)
|
||||
registeredMcp.delete(sessionID)
|
||||
if (entry) yield* Scope.close(entry.scope, Exit.void)
|
||||
if (entry) yield* remove(sessionID, entry)
|
||||
}),
|
||||
require: Effect.fn("cli.acp.sessions.require")(function* (sessionID) {
|
||||
const entry = sessions.get(sessionID)
|
||||
if (!entry) return yield* new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
||||
return entry.attached
|
||||
}),
|
||||
fork: Effect.fn("cli.acp.sessions.fork")(function* (attached, effect) {
|
||||
const entry = sessions.get(attached.id)
|
||||
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
|
||||
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export function currentModel(catalog: Catalog, selection: Selection) {
|
||||
return selection.model ?? catalog.defaultModel
|
||||
}
|
||||
|
||||
export function configOptions(catalog: Catalog, selection: Selection) {
|
||||
const model = currentModel(catalog, selection)
|
||||
return buildConfigOptions({
|
||||
providers: catalog.providers,
|
||||
currentModel: { providerID: model.providerID, modelID: model.id },
|
||||
currentVariant: model.variant,
|
||||
modes: catalog.modes,
|
||||
currentModeId: selection.modeID ?? catalog.defaultModeID,
|
||||
})
|
||||
}
|
||||
|
||||
function mcpConfig(server: McpServer) {
|
||||
if ("type" in server) {
|
||||
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
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"
|
||||
const CompactionMeta = "opencode/compaction"
|
||||
|
||||
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: string }
|
||||
|
||||
export type Terminal = "succeeded" | "failed" | "interrupted"
|
||||
|
||||
export type Context = {
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly childUpdates: boolean
|
||||
/** A background consumer follows open children after the parent turn ends; it never writes `session/update`. */
|
||||
readonly mode: "turn" | "background"
|
||||
}
|
||||
|
||||
type Tool = {
|
||||
readonly sessionID: string
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly input: ToolInput
|
||||
readonly metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
type RetryStatus = {
|
||||
readonly attempt: number
|
||||
readonly nextRetryAt: string
|
||||
readonly error: SessionStructuredError
|
||||
}
|
||||
|
||||
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: ReadonlyMap<string, string>
|
||||
readonly children: ReadonlyMap<string, ChildSession>
|
||||
readonly openChildren: ReadonlySet<string>
|
||||
readonly finish?: SessionMessageAssistant["finish"]
|
||||
readonly usage?: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo }
|
||||
readonly stepError?: SessionStructuredError
|
||||
readonly executionError?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
|
||||
export type Output =
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
|
||||
| { readonly _tag: "ChildUpdate"; readonly update: ChildSessionUpdate }
|
||||
| {
|
||||
readonly _tag: "PermissionAsk"
|
||||
readonly event: PermissionEvent
|
||||
readonly tool?: Tool
|
||||
readonly child?: ChildSession
|
||||
}
|
||||
| { readonly _tag: "FormCancel"; readonly sessionID: string; readonly formID: string }
|
||||
|
||||
export type Step = {
|
||||
readonly state: TurnState
|
||||
readonly outputs: ReadonlyArray<Output>
|
||||
readonly terminal?: Terminal
|
||||
}
|
||||
|
||||
type CompactionEvent = Extract<
|
||||
EventSubscribeOutput,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
type CompactionMarker = {
|
||||
readonly status: "started" | "completed" | "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error?: SessionStructuredError
|
||||
}
|
||||
|
||||
export const initial: TurnState = {
|
||||
started: false,
|
||||
tools: new Map(),
|
||||
retries: new Map(),
|
||||
compactions: new Map(),
|
||||
children: new Map(),
|
||||
openChildren: new Set(),
|
||||
}
|
||||
|
||||
export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context): Step {
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) return { state, outputs: [] }
|
||||
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
|
||||
if (!parent && (ctx.mode === "background" || parentID !== ctx.sessionID)) return { state, outputs: [] }
|
||||
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
children: new Map(state.children).set(child.id, child),
|
||||
openChildren: new Set(state.openChildren).add(child.id),
|
||||
},
|
||||
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
|
||||
}
|
||||
}
|
||||
|
||||
const eventSessionID = sessionIDFromEvent(event)
|
||||
const child = eventSessionID ? state.children.get(eventSessionID) : undefined
|
||||
if (ctx.mode === "background" && !child) return { state, outputs: [] }
|
||||
const send = (update: SessionUpdate) => route(ctx, child, update)
|
||||
|
||||
if (event.type === "permission.asked" && (event.data.sessionID === ctx.sessionID || child)) {
|
||||
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 }] }
|
||||
}
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
|
||||
return {
|
||||
state,
|
||||
outputs: [{ _tag: "FormCancel", sessionID: event.data.form.sessionID, formID: event.data.form.id }],
|
||||
}
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
|
||||
if (matchesStart(event, ctx.start)) return { state: { ...state, started: true }, outputs: [] }
|
||||
if (!state.started) return { state, outputs: [] }
|
||||
|
||||
switch (event.type) {
|
||||
case "session.execution.started":
|
||||
return { state, outputs: child ? childStatus(ctx, child, { type: "status", status: "running" }) : [] }
|
||||
case "session.step.started": {
|
||||
const next = child ? state : { ...state, stepError: undefined }
|
||||
if (!state.retries.has(eventSessionID)) return { state: next, outputs: [] }
|
||||
return {
|
||||
state: { ...next, retries: without(state.retries, eventSessionID) },
|
||||
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } }),
|
||||
}
|
||||
}
|
||||
case "session.retry.scheduled": {
|
||||
const retry = {
|
||||
attempt: event.data.attempt,
|
||||
nextRetryAt: new Date(event.data.at).toISOString(),
|
||||
error: event.data.error,
|
||||
}
|
||||
return {
|
||||
state: { ...state, retries: new Map(state.retries).set(eventSessionID, retry) },
|
||||
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } }),
|
||||
}
|
||||
}
|
||||
case "session.compaction.started":
|
||||
case "session.compaction.ended":
|
||||
case "session.compaction.failed": {
|
||||
const compacted = compactionMarker(event, state.compactions)
|
||||
return {
|
||||
state: { ...state, compactions: compacted.compactions },
|
||||
outputs: compacted.marker ? send(compactionUpdate(compacted.marker)) : [],
|
||||
}
|
||||
}
|
||||
case "session.text.delta":
|
||||
return {
|
||||
state,
|
||||
outputs: send({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
}),
|
||||
}
|
||||
case "session.reasoning.delta":
|
||||
return {
|
||||
state,
|
||||
outputs: send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
}),
|
||||
}
|
||||
case "session.tool.input.started":
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
tools: new Map(state.tools).set(
|
||||
toolKey(event.data.sessionID, event.data.id),
|
||||
newTool(event.data.sessionID, event.data.id, event.data.name),
|
||||
),
|
||||
},
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: event.data.id,
|
||||
toolName: event.data.name,
|
||||
state: { input: {} },
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
case "session.tool.called": {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const tool = {
|
||||
...(state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)),
|
||||
input: event.data.input,
|
||||
}
|
||||
return {
|
||||
state: { ...state, tools: new Map(state.tools).set(key, tool) },
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: tool.name,
|
||||
state: { input: tool.input },
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.tool.progress": {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = state.tools.get(key)
|
||||
if (!current) return { state, outputs: [] }
|
||||
return {
|
||||
state: { ...state, tools: new Map(state.tools).set(key, { ...current, metadata: event.data.metadata }) },
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.tool.success": {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const tool = state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)
|
||||
return {
|
||||
state: { ...state, tools: without(state.tools, key) },
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.tool.failed": {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const tool = state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)
|
||||
return {
|
||||
state: { ...state, tools: without(state.tools, key) },
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: event.data.metadata ?? tool.metadata,
|
||||
content: event.data.content ?? [],
|
||||
error: event.data.error.message,
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.step.ended":
|
||||
if (child) return { state, outputs: [] }
|
||||
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
|
||||
case "session.step.failed": {
|
||||
if (child) return { state, outputs: [] }
|
||||
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
|
||||
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
|
||||
}
|
||||
case "session.execution.succeeded":
|
||||
if (!child) return { state, outputs: [], terminal: "succeeded" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "completed" }, "succeeded")
|
||||
case "session.execution.interrupted":
|
||||
if (!child) return { state, outputs: [], terminal: "interrupted" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "interrupted" }, "interrupted")
|
||||
case "session.execution.failed":
|
||||
if (!child) return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error }, "failed")
|
||||
default:
|
||||
return { state, outputs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/** The ACP failure a settled turn reports instead of a response, if any. */
|
||||
export function failure(state: TurnState) {
|
||||
const error = state.stepError ?? state.executionError
|
||||
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
return new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
|
||||
const tokens = state.usage?.turn
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: TokenUsage.total(tokens),
|
||||
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
|
||||
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
|
||||
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
|
||||
}
|
||||
: undefined
|
||||
const error = (state.stepError ?? state.executionError)?.type
|
||||
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
|
||||
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
|
||||
const retry = state.retries.get(sessionID)
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
/** Fails the tools a cancelled turn left open, for when the server's wind-down never reports them. */
|
||||
export function abandonTools(state: TurnState, ctx: Context): Step {
|
||||
return {
|
||||
state: { ...state, tools: new Map() },
|
||||
outputs: [...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy, so a message that fails to translate part way still replays the updates before the failure. */
|
||||
export function* replayMessage(message: SessionMessageInfo, cwd: string): 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
|
||||
}
|
||||
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
|
||||
if (message.type === "compaction" && message.status !== "running") {
|
||||
yield compactionUpdate({
|
||||
status: message.status,
|
||||
messageId: message.id,
|
||||
reason: message.reason,
|
||||
...(message.status === "failed" ? { error: message.error } : {}),
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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[] {
|
||||
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
|
||||
const projected = projectChildUpdate(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[] {
|
||||
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,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// A background consumer ends once its last open child settles.
|
||||
function childEnded(
|
||||
state: TurnState,
|
||||
ctx: Context,
|
||||
child: ChildSession,
|
||||
status: ChildSessionEvent,
|
||||
terminal: Terminal,
|
||||
): Step {
|
||||
const openChildren = new Set(state.openChildren)
|
||||
openChildren.delete(child.id)
|
||||
return {
|
||||
state: { ...state, openChildren },
|
||||
outputs: childStatus(ctx, child, status),
|
||||
...(ctx.mode === "background" && openChildren.size === 0 ? { terminal } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function recordStep(state: TurnState, tokens: TokenUsageInfo): TurnState {
|
||||
const turn = state.usage?.turn
|
||||
return {
|
||||
...state,
|
||||
usage: {
|
||||
turn: turn
|
||||
? {
|
||||
input: turn.input + tokens.input,
|
||||
output: turn.output + tokens.output,
|
||||
reasoning: turn.reasoning + tokens.reasoning,
|
||||
cache: { read: turn.cache.read + tokens.cache.read, write: turn.cache.write + tokens.cache.write },
|
||||
}
|
||||
: tokens,
|
||||
last: tokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function without<K, V>(map: ReadonlyMap<K, V>, key: K) {
|
||||
const next = new Map(map)
|
||||
next.delete(key)
|
||||
return next
|
||||
}
|
||||
|
||||
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||
if (event.type === "form.created") return event.data.form.sessionID
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
|
||||
function compactionMarker(event: CompactionEvent, compactions: ReadonlyMap<string, string>) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
|
||||
const marker: CompactionMarker = { status: "started", messageId, reason: event.data.reason }
|
||||
return { marker, compactions: new Map(compactions).set(sessionID, messageId) }
|
||||
}
|
||||
const tracked = compactions.get(sessionID)
|
||||
const remaining = without(compactions, sessionID)
|
||||
if (event.type === "session.compaction.ended") {
|
||||
const marker: CompactionMarker | undefined = tracked
|
||||
? { status: "completed", messageId: tracked, reason: event.data.reason }
|
||||
: undefined
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
|
||||
const marker: CompactionMarker = {
|
||||
status: "failed",
|
||||
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
|
||||
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
|
||||
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
|
||||
return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
readonly terminal: Terminal
|
||||
readonly finish: SessionMessageAssistant["finish"]
|
||||
readonly error?: string
|
||||
}): PromptResponse["stopReason"] {
|
||||
if (input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
|
||||
if (input.finish === "length") return "max_tokens"
|
||||
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
|
||||
return "end_turn"
|
||||
}
|
||||
|
||||
export * as ACPTranslate from "./translate"
|
||||
@@ -0,0 +1,411 @@
|
||||
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
isSessionNotFoundError,
|
||||
type CommandInfo,
|
||||
type OpenCodeClient,
|
||||
type OpenCodeEvent,
|
||||
} from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import {
|
||||
Cause,
|
||||
Context,
|
||||
Deferred,
|
||||
Duration,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
FiberMap,
|
||||
Option,
|
||||
Queue,
|
||||
Ref,
|
||||
Scope,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
|
||||
import { currentModel } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { promptContentToParts } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPermission } from "./permission"
|
||||
import { ACPPromise } from "./promise"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
|
||||
type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Runs the session's only turn. Cancelling it, including through the request's `$/cancel_request` signal,
|
||||
* interrupts the turn and still resolves with `stopReason: "cancelled"`.
|
||||
*/
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
|
||||
/** Interrupts the session's active turn and waits for it to settle. No-op when the session is idle. */
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
|
||||
/** Like `cancel`, but an idle session is still interrupted, 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") {}
|
||||
|
||||
/**
|
||||
* How long a cancelled turn keeps forwarding the server's wind-down. Core acknowledges an interrupt before its
|
||||
* cleanup settles, and its shell tool waits 3 seconds before escalating to SIGKILL.
|
||||
*/
|
||||
export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/cli/acp/Turn/CancelDrainTimeout", {
|
||||
defaultValue: () => "5 seconds",
|
||||
})
|
||||
|
||||
type PreparedPrompt = {
|
||||
readonly start: ACPTranslate.TurnStart
|
||||
readonly text: string
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: CommandInfo
|
||||
}
|
||||
|
||||
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
|
||||
|
||||
/** A turn's event feed. It moves to the session scope when the turn ends with children still running. */
|
||||
type Subscription = {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly events: Queue.Dequeue<OpenCodeEvent, unknown>
|
||||
/** Runs permission replies one at a time in ask order, without holding back the rest of the stream. */
|
||||
readonly permissions: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
|
||||
/** Completed when the turn is cancelled; pending and later asks are then rejected. */
|
||||
readonly cancelled: Deferred.Deferred<void>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly sessions: ACPSessions.Interface
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
readonly capabilities: Ref.Ref<{ readonly childSessionUpdates: boolean }>
|
||||
}) {
|
||||
const scope = yield* Effect.scope
|
||||
const drainTimeout = yield* CancelDrainTimeout
|
||||
const turns = yield* FiberMap.make<string, PromptResponse, Failure>()
|
||||
|
||||
const subscribe = Effect.fnUntraced(function* () {
|
||||
// Parented, so it still closes when the session scope it is handed to is already gone.
|
||||
const subscriptionScope = yield* Scope.fork(scope)
|
||||
const subscription: Subscription = {
|
||||
scope: subscriptionScope,
|
||||
events: yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.toQueue({ capacity: "unbounded" }),
|
||||
Scope.provide(subscriptionScope),
|
||||
),
|
||||
permissions: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
}
|
||||
yield* Queue.take(subscription.permissions).pipe(
|
||||
Effect.flatten,
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP permission reply failed", cause),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkIn(subscriptionScope),
|
||||
)
|
||||
return subscription
|
||||
})
|
||||
|
||||
const take = (subscription: Subscription) =>
|
||||
Queue.take(subscription.events).pipe(
|
||||
Effect.catch((error) =>
|
||||
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPPromise.classify(error),
|
||||
),
|
||||
)
|
||||
|
||||
// A turn settles only after the permission asks it saw have been answered.
|
||||
const permissionsSettled = Effect.fnUntraced(function* (subscription: Subscription) {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
yield* Queue.offer(subscription.permissions, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
|
||||
yield* Deferred.await(settled)
|
||||
})
|
||||
|
||||
const reply = (subscription: Subscription, ctx: ACPTranslate.Context, ask: PermissionAsk) =>
|
||||
ACPPermission.reply(
|
||||
{
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event: ask.event,
|
||||
sessionID: ask.event.data.sessionID,
|
||||
clientSessionID: ctx.sessionID,
|
||||
cwd: ctx.cwd,
|
||||
tool: ask.tool,
|
||||
...(ask.child ? { toolCallPrefix: ask.child.id, titlePrefix: ask.child.title } : {}),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
)
|
||||
|
||||
const interpret = (subscription: Subscription, ctx: ACPTranslate.Context, output: ACPTranslate.Output) => {
|
||||
switch (output._tag) {
|
||||
case "SessionUpdate":
|
||||
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
|
||||
case "ChildUpdate":
|
||||
return input.connection
|
||||
.extNotification(ACPTranslate.ChildSessionUpdateMethod, output.update)
|
||||
.pipe(Effect.ignoreCause)
|
||||
case "PermissionAsk":
|
||||
return Queue.offer(subscription.permissions, reply(subscription, ctx, output)).pipe(Effect.asVoid)
|
||||
case "FormCancel":
|
||||
return Effect.tryPromise(() =>
|
||||
input.client.session.form.cancel({ sessionID: output.sessionID, formID: output.formID }),
|
||||
).pipe(Effect.catch(() => interruptServer(output.sessionID)))
|
||||
}
|
||||
}
|
||||
|
||||
const consume = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
) {
|
||||
while (true) {
|
||||
const event = yield* take(subscription)
|
||||
const next = ACPTranslate.step(yield* Ref.get(state), event, ctx)
|
||||
yield* Ref.set(state, next.state)
|
||||
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
|
||||
if (next.terminal) {
|
||||
yield* permissionsSettled(subscription)
|
||||
return next.terminal
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const sessionID = attached.id
|
||||
if (prompt.synthetic.length > 0) {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.synthetic(
|
||||
{
|
||||
sessionID,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (prompt.start.type === "compaction") {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.compact({ sessionID, id: prompt.start.id }, { signal }),
|
||||
)
|
||||
return
|
||||
}
|
||||
const command = prompt.command
|
||||
if (command) {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.command(
|
||||
{ sessionID, name: command.name, text: prompt.slash?.args ?? "", files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.prompt(
|
||||
{ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const interruptServer = (sessionID: string) =>
|
||||
ACPPromise.promise(() => input.client.session.interrupt({ sessionID })).pipe(Effect.ignoreCause)
|
||||
|
||||
// Rejects pending asks, interrupts the server once, then forwards its wind-down until the terminal event or the
|
||||
// timeout. Tools still open at the timeout are reported failed so the client never shows them running.
|
||||
const windDown = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
events: Fiber.Fiber<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
yield* Deferred.succeed(subscription.cancelled, undefined)
|
||||
yield* interruptServer(ctx.sessionID)
|
||||
if (!(yield* Ref.get(state)).started) return
|
||||
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
|
||||
yield* Fiber.interrupt(events)
|
||||
const abandoned = ACPTranslate.abandonTools(yield* Ref.get(state), ctx)
|
||||
yield* Ref.set(state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
const execute = (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
subscribe(),
|
||||
(subscription) =>
|
||||
Effect.gen(function* () {
|
||||
// The feed opens with `server.connected`, so every event the submission causes comes after it.
|
||||
const connected = yield* take(subscription)
|
||||
if (connected.type !== "server.connected")
|
||||
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
|
||||
const events = yield* consume(subscription, ctx, state).pipe(Effect.forkScoped)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* submit(attached, prompt)
|
||||
if (prompt.command) return "succeeded" as const
|
||||
return yield* Fiber.join(events)
|
||||
}).pipe(Effect.onInterrupt(() => windDown(subscription, ctx, state, events)))
|
||||
}).pipe(Effect.scoped),
|
||||
(subscription, exit) => handoff(attached, subscription, ctx, state, exit),
|
||||
)
|
||||
|
||||
const handoff = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
const close = Scope.close(subscription.scope, Exit.void)
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
|
||||
if ((yield* Ref.get(state)).openChildren.size === 0) return yield* close
|
||||
// Children that outlive a cancelled turn were not cancelled, so their asks still go to the client.
|
||||
const cancelled = yield* Deferred.make<void>()
|
||||
const background = consume({ ...subscription, cancelled }, { ...ctx, mode: "background" }, state).pipe(
|
||||
Effect.ignore,
|
||||
Effect.ensuring(close),
|
||||
Effect.withSpan("cli.acp.turn.background"),
|
||||
)
|
||||
yield* input.sessions.fork(attached, background).pipe(Effect.catchTag("ACPSessionNotFoundError", () => close))
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
const current = yield* Ref.get(state)
|
||||
const failure = ACPTranslate.failure(current)
|
||||
if (failure) return yield* failure
|
||||
yield* sendUsageUpdate(attached, current)
|
||||
return ACPTranslate.response(current, attached.id, Exit.isSuccess(exit) ? exit.value : "interrupted")
|
||||
})
|
||||
|
||||
const sendUsageUpdate = Effect.fn("cli.acp.turn.usage")(
|
||||
function* (attached: Attached, state: ACPTranslate.TurnState) {
|
||||
const used = state.usage ? TokenUsage.total(state.usage.last) : 0
|
||||
if (!used) return
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model?.limit.context) return
|
||||
const info = yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.get({ sessionID: attached.id }, { signal }),
|
||||
)
|
||||
yield* input.connection.sessionUpdate({
|
||||
sessionId: attached.id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used,
|
||||
size: model.limit.context,
|
||||
cost: { amount: info.cost, currency: "USD" },
|
||||
},
|
||||
})
|
||||
},
|
||||
(effect) => Effect.ignoreCause(effect),
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
childUpdates: boolean,
|
||||
) {
|
||||
const state = yield* Ref.make(ACPTranslate.initial)
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates,
|
||||
mode: "turn",
|
||||
}
|
||||
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
|
||||
return yield* settle(attached, state, exit)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const childUpdates = (yield* Ref.get(input.capabilities)).childSessionUpdates
|
||||
const prompt = preparePrompt(catalog, params.prompt, SessionMessage.ID.create())
|
||||
// Check and register in one synchronous step.
|
||||
const turn = yield* Effect.withFiber((fiber) => {
|
||||
if (FiberMap.hasUnsafe(turns, attached.id)) {
|
||||
return Effect.fail(
|
||||
new ACPError.ServiceFailureError({
|
||||
safeMessage: `Session already has an active ACP prompt: ${attached.id}`,
|
||||
service: "session",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt, childUpdates), { uninterruptible: true })
|
||||
FiberMap.setUnsafe(turns, attached.id, forked)
|
||||
return Effect.succeed(forked)
|
||||
})
|
||||
// A `$/cancel_request` for this prompt cancels its turn like `session/cancel`, rather than failing the request.
|
||||
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) {
|
||||
yield* FiberMap.remove(turns, params.sessionId)
|
||||
}),
|
||||
close: Effect.fn("cli.acp.turn.close")(function* (sessionID) {
|
||||
if (FiberMap.hasUnsafe(turns, sessionID)) return yield* FiberMap.remove(turns, sessionID)
|
||||
yield* ACPPromise.promise(() =>
|
||||
input.client.session.interrupt({ sessionID }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
function aborted(signal: AbortSignal) {
|
||||
return Effect.callback<void>((resume) => {
|
||||
if (signal.aborted) return resume(Effect.void)
|
||||
const abort = () => resume(Effect.void)
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", abort))
|
||||
})
|
||||
}
|
||||
|
||||
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
|
||||
const parts = promptContentToParts(prompt)
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
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 }
|
||||
}
|
||||
|
||||
function turnStart(messageID: string, 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 {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
if (!name) return undefined
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
export * as ACPTurn from "./turn"
|
||||
@@ -118,104 +118,73 @@ describe("acp catalog and config options over the wire", () => {
|
||||
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 acp.request("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
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(
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "mode", value: "missing" }),
|
||||
)
|
||||
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("reloads the catalog once for an unseen model and selects it", async () => {
|
||||
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 session = await acp.newSession()
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
const set = (value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "model", value })
|
||||
const reads = modelReads(acp)
|
||||
|
||||
const selected = await set("test/second-model")
|
||||
expect(modelReads(acp)).toBe(reads + 1)
|
||||
expect(await rpcError(set("test/missing-model"))).toMatchObject({ code: -32602 })
|
||||
|
||||
expect(currentValue(selected, "model")).toBe("test/second-model")
|
||||
expect(modelReads(acp)).toBe(reads + 2)
|
||||
expect(acp.server.selections).toEqual([
|
||||
{ sessionID: session.sessionId, model: { providerID: "test", id: secondModel.id } },
|
||||
])
|
||||
})
|
||||
|
||||
test("stops catalog updates for a closed session while other sessions in the cwd keep them", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const closed = await acp.newSession()
|
||||
const open = await acp.newSession()
|
||||
await acp.request("session/close", { sessionId: closed.sessionId })
|
||||
const sessionId = await setup(acp)
|
||||
const since = acp.updates.length
|
||||
|
||||
await change(acp, open.sessionId, "config_option_update", () => {
|
||||
await change(acp, sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
})
|
||||
await change(acp, open.sessionId, "available_commands_update", () => {
|
||||
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" }))
|
||||
})
|
||||
await change(acp, open.sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.agents = [buildAgent]
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
|
||||
expect(updateKinds(acp, since)).toEqual([
|
||||
[open.sessionId, "config_option_update"],
|
||||
[open.sessionId, "available_commands_update"],
|
||||
[open.sessionId, "config_option_update"],
|
||||
[sessionId, "config_option_update"],
|
||||
[sessionId, "available_commands_update"],
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["session/load", "session/resume"] as const)(
|
||||
"pushes one update per catalog change after %s re-attaches a session",
|
||||
async (method) => {
|
||||
await using acp = await startSession()
|
||||
const params = { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] }
|
||||
await acp.request(method, params)
|
||||
await acp.request(method, params)
|
||||
const since = acp.updates.length
|
||||
|
||||
await change(acp, acp.sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.models = [testModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
})
|
||||
await change(acp, acp.sessionId, "available_commands_update", () => {
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
await change(acp, acp.sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.agents = [buildAgent]
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
|
||||
expect(updateKinds(acp, since)).toEqual([
|
||||
[acp.sessionId, "config_option_update"],
|
||||
[acp.sessionId, "available_commands_update"],
|
||||
[acp.sessionId, "config_option_update"],
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["empty", "missing the default"])(
|
||||
"retries when the model list is %s but the default is ready",
|
||||
async (initial) => {
|
||||
@@ -292,20 +261,32 @@ describe("acp catalog and config options over the wire", () => {
|
||||
await using acp = await startSession()
|
||||
const advertised = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "compact", description: "Server compact" }]
|
||||
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(
|
||||
Array.from({ length: 2 }, () => ({
|
||||
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"])
|
||||
})
|
||||
@@ -322,6 +303,14 @@ async function change(acp: Wire, sessionId: string, kind: string, trigger: () =>
|
||||
)
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ describe("acp errors", () => {
|
||||
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])
|
||||
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
|
||||
-32602, -32602, -32602, -32602, -32602, -32602,
|
||||
])
|
||||
})
|
||||
|
||||
test("includes safe validation details", () => {
|
||||
@@ -79,6 +82,25 @@ describe("acp error boundary over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import { Cause } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
@@ -132,6 +133,33 @@ describe("acp permissions over the wire", () => {
|
||||
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")
|
||||
|
||||
expect(acp.permissions).toMatchObject([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_background:perm_background", title: "Research: /workspace/notes.md" },
|
||||
},
|
||||
])
|
||||
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 }) =>
|
||||
@@ -159,6 +187,23 @@ describe("acp permissions over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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: { name: "ClientError", reason: "UnexpectedStatus" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("serializes permission requests and replies within one session", async () => {
|
||||
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startSession({
|
||||
@@ -185,6 +230,34 @@ describe("acp permissions over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
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 })
|
||||
@@ -251,6 +324,32 @@ describe("acp permissions over the wire", () => {
|
||||
params: { requestId: asked?.id },
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects asks queued behind a cancelled one without sending them to the client", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
permissionAsked(sessionID, "perm_pending"),
|
||||
permissionAsked(sessionID, "perm_queued"),
|
||||
],
|
||||
onInterrupt: ({ 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(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_pending"])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_pending", "reject"],
|
||||
["perm_queued", "reject"],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp edit previews over the wire", () => {
|
||||
@@ -346,6 +445,36 @@ describe("acp edit previews over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
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" },
|
||||
}),
|
||||
],
|
||||
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")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StopReason } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
@@ -14,6 +15,9 @@ import {
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
toolCalled,
|
||||
toolFailed,
|
||||
toolStarted,
|
||||
turn,
|
||||
type Wire,
|
||||
type WireOptions,
|
||||
@@ -293,6 +297,20 @@ describe("acp prompt turns over the wire", () => {
|
||||
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)
|
||||
|
||||
@@ -305,6 +323,91 @@ describe("acp prompt turns over the wire", () => {
|
||||
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"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [
|
||||
toolFailed(sessionID, "call_sleep", { error: { type: "aborted", message: "interrupted" } }),
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_held",
|
||||
error: { type: "aborted", message: "interrupted" },
|
||||
cost: 0,
|
||||
tokens: { ...tokens(), input: 30, output: 3 },
|
||||
}),
|
||||
interrupted(sessionID),
|
||||
],
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await admitted(acp, acp.sessionId)
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: { inputTokens: 30, outputTokens: 3, totalTokens: 33 },
|
||||
_meta: {},
|
||||
})
|
||||
expect(receivedBeforeResponse(acp)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ sessionUpdate: "tool_call_update", toolCallId: "call_sleep", status: "failed" }),
|
||||
expect.objectContaining({ sessionUpdate: "usage_update", used: 33 }),
|
||||
]),
|
||||
)
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
})
|
||||
|
||||
test("stops waiting for a wind-down that never ends and fails the tools left running", async () => {
|
||||
await using acp = await startSession({
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_stuck", "shell"),
|
||||
toolCalled(sessionID, "call_stuck", { command: "sleep 60" }),
|
||||
textDelta(sessionID, "msg_held", "working"),
|
||||
],
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await admitted(acp, acp.sessionId)
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
expect(receivedBeforeResponse(acp)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call_stuck",
|
||||
status: "failed",
|
||||
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("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
|
||||
await using acp = await startSession(held)
|
||||
const controller = new AbortController()
|
||||
@@ -469,6 +572,20 @@ async function admitted(acp: Wire, sessionId: string) {
|
||||
await acp.request("session/set_mode", { sessionId, modeId: "build" })
|
||||
}
|
||||
|
||||
const isCancelledResponse = Schema.is(
|
||||
Schema.Struct({ result: Schema.Struct({ stopReason: Schema.Literal("cancelled") }) }),
|
||||
)
|
||||
|
||||
// Session updates the client received before the cancelled prompt response.
|
||||
function receivedBeforeResponse(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 acp.updates.slice(0, count).map((item) => item.update)
|
||||
}
|
||||
|
||||
function retryScheduled(sessionID: string, attempt: number, at: number) {
|
||||
return durableEvent("session.retry.scheduled", {
|
||||
sessionID,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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,7 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { McpServer, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import { currentValue } from "./select-options"
|
||||
import { makeSession, rpcError, secondModel, startSession, startWire } from "./wire-fixture"
|
||||
import {
|
||||
ephemeralEvent,
|
||||
makeSession,
|
||||
rpcError,
|
||||
secondModel,
|
||||
startSession,
|
||||
startWire,
|
||||
testModel,
|
||||
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 () => {
|
||||
@@ -76,7 +86,7 @@ describe("acp session lifecycle over the wire", () => {
|
||||
expect(acp.server.selections).toEqual([])
|
||||
})
|
||||
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
test("loads with paginated replay while resume and fork do not replay", async () => {
|
||||
await using acp = await startWire()
|
||||
const history = Array.from({ length: 201 }, (_, index) => ({
|
||||
id: `msg_${index}`,
|
||||
@@ -122,7 +132,7 @@ describe("acp session lifecycle over the wire", () => {
|
||||
: [],
|
||||
)
|
||||
expect(replayed("ses_loaded")).toEqual(history.map((message) => message.id))
|
||||
expect(replayed(forked.sessionId)).toEqual(history.map((message) => message.id))
|
||||
expect(replayed(forked.sessionId)).toEqual([])
|
||||
expect(replayed("ses_resume")).toEqual([])
|
||||
expect(
|
||||
acp.updates.find((item) => item.sessionId === "ses_loaded" && item.update.sessionUpdate === "user_message_chunk")
|
||||
@@ -134,6 +144,67 @@ describe("acp session lifecycle over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("publishes a session's commands after the response that attaches it", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
|
||||
acp.server.messages.set("ses_loaded", [{ id: "msg_0", type: "user", text: "hello", time: { created: 0 } }])
|
||||
await acp.initialize()
|
||||
const params = { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }
|
||||
|
||||
expect(await untilCommands(acp, () => acp.newSession())).toEqual(["response", "available_commands_update"])
|
||||
expect(await untilCommands(acp, () => acp.request("session/load", params))).toEqual([
|
||||
"user_message_chunk",
|
||||
"response",
|
||||
"available_commands_update",
|
||||
])
|
||||
expect(await untilCommands(acp, () => acp.request("session/resume", params))).toEqual([
|
||||
"response",
|
||||
"available_commands_update",
|
||||
])
|
||||
expect(await untilCommands(acp, () => acp.request("session/fork", params))).toEqual([
|
||||
"response",
|
||||
"available_commands_update",
|
||||
])
|
||||
})
|
||||
|
||||
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("lists server-backed pages for the requested cwd", async () => {
|
||||
await using acp = await startWire()
|
||||
Array.from({ length: 101 }, (_, index) =>
|
||||
@@ -180,6 +251,8 @@ describe("acp session lifecycle over the wire", () => {
|
||||
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" }),
|
||||
@@ -187,6 +260,38 @@ describe("acp session lifecycle over the wire", () => {
|
||||
).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: [] }
|
||||
|
||||
expect(await rpcError(acp.request("session/load", params))).toEqual({
|
||||
code: -32602,
|
||||
message: 'Invalid params: Expected a string starting with "ses"',
|
||||
data: {},
|
||||
})
|
||||
expect(await rpcError(acp.request("session/fork", params))).toEqual({
|
||||
code: -32602,
|
||||
message: "Invalid params: Invalid session ID",
|
||||
data: { field: "sessionID" },
|
||||
})
|
||||
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",
|
||||
@@ -226,4 +331,58 @@ describe("acp session lifecycle over the wire", () => {
|
||||
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,
|
||||
})
|
||||
await acp.initialize()
|
||||
const failed = await acp.newSession()
|
||||
const other = await acp.newSession()
|
||||
await acp.until(() => acp.updates.filter(isCommands).length === 2, "initial commands")
|
||||
|
||||
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 } })
|
||||
})
|
||||
})
|
||||
|
||||
const isSessionUpdate = Schema.is(
|
||||
Schema.Struct({
|
||||
method: Schema.Literal("session/update"),
|
||||
params: Schema.Struct({ update: Schema.Struct({ sessionUpdate: Schema.String }) }),
|
||||
}),
|
||||
)
|
||||
|
||||
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
|
||||
await send()
|
||||
return acp.until(() => {
|
||||
const labels = acp.received.slice(start).map((message) => {
|
||||
if (isSessionUpdate(message)) return message.params.update.sessionUpdate
|
||||
return "method" in message ? message.method : "response"
|
||||
})
|
||||
return labels.includes("available_commands_update") && labels
|
||||
}, "available commands")
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { ACPTranslate } from "../../src/acp/translate"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
toolCalled,
|
||||
toolProgress,
|
||||
toolStarted,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const root = "ses_root"
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: root,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_input" },
|
||||
childUpdates: false,
|
||||
mode: "turn",
|
||||
}
|
||||
|
||||
function run(events: ReadonlyArray<OpenCodeEvent>, 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, { ...event, id: `evt_${index + 1}` }, context)
|
||||
return {
|
||||
state: next.state,
|
||||
outputs: [...acc.outputs, ...next.outputs],
|
||||
...(next.terminal ? { terminal: next.terminal } : {}),
|
||||
}
|
||||
},
|
||||
{ state, outputs: [] },
|
||||
)
|
||||
}
|
||||
|
||||
function started(...events: OpenCodeEvent[]) {
|
||||
return run([delivered(root, "msg_input"), ...events])
|
||||
}
|
||||
|
||||
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"),
|
||||
delivered("ses_other", "msg_input"),
|
||||
succeeded(root),
|
||||
delivered(root, "msg_input"),
|
||||
textDelta("ses_other", "msg_other", "other session"),
|
||||
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(
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID: root,
|
||||
assistantMessageID: "msg_1",
|
||||
error: { type: "provider.stream", message: "stream interrupted" },
|
||||
cost: 0,
|
||||
tokens: { ...tokens(), input: 40, output: 4 },
|
||||
}),
|
||||
durableEvent("session.step.started", {
|
||||
sessionID: root,
|
||||
assistantMessageID: "msg_2",
|
||||
agent: "build",
|
||||
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" },
|
||||
}),
|
||||
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(
|
||||
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" } },
|
||||
}),
|
||||
])
|
||||
expect([...result.state.openChildren]).toEqual(["ses_child", "ses_grandchild"])
|
||||
})
|
||||
|
||||
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 background = { ...ctx, mode: "background" as const }
|
||||
const first = run(
|
||||
[textDelta(root, "msg_root", "ignored"), toolStarted("ses_a", "call_1", "read"), succeeded("ses_a")],
|
||||
background,
|
||||
turn.state,
|
||||
)
|
||||
const last = run([childCreated("ses_later", root, "Later"), interrupted("ses_b")], background, first.state)
|
||||
|
||||
expect(turn.terminal).toBe("succeeded")
|
||||
expect(first.outputs).toEqual([])
|
||||
expect(first.terminal).toBeUndefined()
|
||||
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.abandonTools(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.abandonTools(abandoned.state, ctx).outputs).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
permissionAsked,
|
||||
reasoningDelta,
|
||||
startSession,
|
||||
stepEnded,
|
||||
@@ -235,6 +237,53 @@ describe("acp turn events over the wire", () => {
|
||||
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"),
|
||||
],
|
||||
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 }) =>
|
||||
|
||||
@@ -28,8 +28,9 @@ import {
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import type { BunRequest } from "bun"
|
||||
import { Effect, Exit, Logger, Option, Schema, Scope } from "effect"
|
||||
import { Duration, Effect, Exit, Logger, Option, Schema, Scope } from "effect"
|
||||
import { ACP } from "../../src/acp/agent"
|
||||
import { ACPTurn } from "../../src/acp/turn"
|
||||
|
||||
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
|
||||
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
|
||||
@@ -121,6 +122,7 @@ export type WireOptions = {
|
||||
request: RequestPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
) => RequestPermissionResponse | Promise<RequestPermissionResponse>
|
||||
readonly cancelDrainTimeout?: Duration.Input
|
||||
}
|
||||
|
||||
type CatalogKind = "model" | "default" | "agent" | "command"
|
||||
@@ -385,6 +387,10 @@ export async function startWire(options: WireOptions = {}) {
|
||||
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
||||
).pipe(
|
||||
Scope.provide(agentScope),
|
||||
(effect) =>
|
||||
options.cancelDrainTimeout === undefined
|
||||
? effect
|
||||
: Effect.provideService(effect, ACPTurn.CancelDrainTimeout, options.cancelDrainTimeout),
|
||||
Effect.provide(Logger.layer([Logger.make((log) => logs.push({ message: log.message, cause: log.cause }))])),
|
||||
),
|
||||
)
|
||||
@@ -571,6 +577,15 @@ 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>) => {
|
||||
@@ -677,15 +692,21 @@ 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) =>
|
||||
fake.sessions.delete(req.params.sessionID) ? noContent() : 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)
|
||||
}),
|
||||
},
|
||||
"/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)
|
||||
@@ -777,6 +798,11 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
|
||||
return Object.assign(fake, {
|
||||
url: http.url.toString(),
|
||||
/** Ends every open event stream while the server keeps answering requests. */
|
||||
closeEvents() {
|
||||
streams.forEach((stream) => stream.close())
|
||||
streams.clear()
|
||||
},
|
||||
async stop() {
|
||||
streams.forEach((stream) => stream.close())
|
||||
streams.clear()
|
||||
|
||||
@@ -543,7 +543,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
yield* failAssistant({
|
||||
type: "provider.content-filter",
|
||||
message: [
|
||||
`Provider blocked the response${event.reason.category === undefined ? "" : ` (${event.reason.category})`}`,
|
||||
event.reason.explanation,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(": "),
|
||||
})
|
||||
return
|
||||
}
|
||||
return
|
||||
|
||||
@@ -667,3 +667,29 @@ test("content-filter finish preserves partial streamed text and never ends the s
|
||||
error: { type: "provider.content-filter" },
|
||||
})
|
||||
})
|
||||
|
||||
test("content-filter failure explains the refusal when the provider gives a reason", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: {
|
||||
normalized: "content-filter",
|
||||
raw: "refusal",
|
||||
category: "cyber",
|
||||
explanation: "This request was declined because it could enable cyber harm.",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(publisher.publishStepFailure())
|
||||
|
||||
expect(published.at(-1)?.data).toMatchObject({
|
||||
error: {
|
||||
type: "provider.content-filter",
|
||||
message: "Provider blocked the response (cyber): This request was declined because it could enable cyber harm.",
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -153,6 +153,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
||||
const pendingSelectionCommits = new Map<string, { agentID: string; selection: string }>()
|
||||
// Recents are shared, but an open session's fallback is local until a durable selection replaces it.
|
||||
const fallbackBySessionAgent = new Map<string, Map<string, ModelSelection>>()
|
||||
const selectionKey = (value: ModelSelection) =>
|
||||
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
|
||||
|
||||
@@ -267,6 +269,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const selected = [
|
||||
selectionState.selectionBySessionAgent[sessionID]?.[current.id],
|
||||
!session?.agent || session.agent === current.id ? durableSelection(sessionID) : undefined,
|
||||
fallbackBySessionAgent.get(sessionID)?.get(current.id),
|
||||
].find((selection) => selection && isModelValid(selection))
|
||||
if (selected) {
|
||||
const info = models()?.find((item) => item.providerID === selected.providerID && item.id === selected.modelID)
|
||||
@@ -276,7 +279,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
const model = newSessionModel()
|
||||
return model && preferredSelection(model)
|
||||
if (!model) return
|
||||
const fallback = preferredSelection(model)
|
||||
const byAgent = fallbackBySessionAgent.get(sessionID) ?? new Map<string, ModelSelection>()
|
||||
byAgent.set(current.id, fallback)
|
||||
fallbackBySessionAgent.set(sessionID, byAgent)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function setSessionSelection(sessionID: string, agentID: string, selection: ModelSelection | undefined) {
|
||||
@@ -344,6 +352,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
pendingSelectionCommits.delete(evt.data.sessionID)
|
||||
fallbackBySessionAgent.delete(evt.data.sessionID)
|
||||
setSelectionState("selectionBySessionAgent", evt.data.sessionID, undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test"
|
||||
import { EmbeddedTerminalRenderable } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import path from "node:path"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
@@ -216,6 +215,7 @@ test.each(["dismissed", "refreshing"])(
|
||||
)
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const titles: string[] = []
|
||||
let started!: () => void
|
||||
@@ -243,7 +243,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
await ready
|
||||
process.emit("SIGHUP")
|
||||
@@ -259,6 +259,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
})
|
||||
|
||||
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
let initialTitle!: () => void
|
||||
const initialTitleSet = new Promise<void>((resolve) => {
|
||||
@@ -319,7 +320,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await initialTitleSet
|
||||
@@ -345,6 +346,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
})
|
||||
|
||||
test("session title generated while an untitled session is loading remains visible", async () => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const titles: string[] = []
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
@@ -393,7 +395,7 @@ test("session title generated while an untitled session is loading remains visib
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await sessionRequested.promise
|
||||
@@ -802,6 +804,7 @@ test("keeps assistant footer metrics current after prepend, same-length refresh,
|
||||
})
|
||||
|
||||
test("session startup prompt is submitted exactly once", async () => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const events = createEventStream()
|
||||
const cwd = process.cwd()
|
||||
@@ -855,7 +858,7 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await Promise.race([
|
||||
@@ -1344,6 +1347,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
|
||||
test.skipIf(process.platform === "win32").each(["manual", "select"] as const)(
|
||||
"selection copy and pane management respect %s mode in the prompt and terminal pane",
|
||||
async (copy) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
@@ -1426,7 +1430,7 @@ test.skipIf(process.platform === "win32").each(["manual", "select"] as const)(
|
||||
args: { sessionID: session.id },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await ready.promise
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { agent, model, renderLocal, session } from "../fixture/local"
|
||||
import { json } from "../fixture/tui-client"
|
||||
import { createModelPreferenceRepository } from "../../src/model-preference"
|
||||
import path from "node:path"
|
||||
|
||||
test("cycles all recent models in a stable order in both directions", async () => {
|
||||
await using setup = await renderLocal({
|
||||
@@ -80,6 +82,34 @@ test("agent and model drafts are isolated across sessions and survive navigation
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
|
||||
})
|
||||
|
||||
test("a model selected in another instance updates recents without changing this session's model", async () => {
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first", ["high"]), model("second")],
|
||||
preferences: { recent: [{ providerID: "provider", modelID: "first" }] },
|
||||
sessions: [session("ses_first"), session("ses_second")],
|
||||
fetch: selectionMessage,
|
||||
})
|
||||
await Promise.all([setup.data.session.sync("ses_first"), setup.data.session.sync("ses_second")])
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: undefined })
|
||||
|
||||
const repository = createModelPreferenceRepository(path.join(setup.state, "model.json"))
|
||||
await repository.addRecent({ providerID: "provider", modelID: "second" })
|
||||
await repository.setFavorite({ providerID: "provider", modelID: "second" }, true)
|
||||
await repository.saveVariant({ providerID: "provider", modelID: "first" }, "high")
|
||||
for (let i = 0; i < 100 && setup.local.model.recent()[0]?.modelID !== "second"; i++) await Bun.sleep(10)
|
||||
expect(setup.local.model.recent()[0]?.modelID).toBe("second")
|
||||
expect(setup.local.model.favorite()[0]?.modelID).toBe("second")
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: undefined })
|
||||
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_second" })
|
||||
expect(setup.local.model.current()?.modelID).toBe("second")
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
expect(setup.local.model.current()?.modelID).toBe("first")
|
||||
await publishSelection(setup, "build", "second")
|
||||
expect(setup.local.model.current()?.modelID).toBe("second")
|
||||
})
|
||||
|
||||
test("falls back from an unavailable session model without changing durable state", async () => {
|
||||
const selected = { providerID: "provider", id: "missing", variant: "high" }
|
||||
await using setup = await renderLocal({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import type { TuiInput } from "../../src/app"
|
||||
import type { Config } from "../../src/config"
|
||||
import { tmpdir } from "./fixture"
|
||||
import { createEventStream, createFetch, type FetchHandler } from "./tui-client"
|
||||
|
||||
export async function createAppFixture(
|
||||
@@ -17,6 +17,7 @@ export async function createAppFixture(
|
||||
} = {},
|
||||
) {
|
||||
const { run } = await import("../../src/app")
|
||||
const state = input.state ? undefined : await tmpdir()
|
||||
const setup = await createTestRenderer({
|
||||
width: input.width ?? 100,
|
||||
height: input.height ?? 30,
|
||||
@@ -38,7 +39,7 @@ export async function createAppFixture(
|
||||
args: input.args ?? {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
|
||||
Effect.provide(Global.layerWith({ state: input.state ?? state?.path })),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
@@ -52,6 +53,7 @@ export async function createAppFixture(
|
||||
await task
|
||||
} finally {
|
||||
await server.stop()
|
||||
await state?.[Symbol.asyncDispose]()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user