1005 lines · 37.0 KB · TypeScript
RawDownload
import * as Cause from "effect/Cause";import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as Crypto from "effect/Crypto";import * as Deferred from "effect/Deferred";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Queue from "effect/Queue";import * as Ref from "effect/Ref";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream";import * as ChildProcess from "effect/unstable/process/ChildProcess";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";import * as EffectAcpClient from "effect-acp/client";import * as EffectAcpErrors from "effect-acp/errors";import type * as EffectAcpSchema from "effect-acp/schema";import type * as EffectAcpProtocol from "effect-acp/protocol";import { resolveSpawnCommand } from "@t3tools/shared/shell"; import {  collectSessionConfigOptionValues,  extractModelConfigId,  findSessionConfigOption,  mergeToolCallState,  parseSessionModeState,  parseSessionUpdateEvent,  sessionUpdateIsReplay,  waitForSessionLoadReplayIdle,  type SessionLoadGate,  type AcpParsedSessionEvent,  type AcpSessionModeState,  type AcpToolCallState,} from "./AcpRuntimeModel.ts"; function formatConfigOptionValue(value: string | boolean): string {  return JSON.stringify(value);} export interface AcpSessionEventStreamBarrier {  readonly _tag: "EventStreamBarrier";  readonly acknowledge: Deferred.Deferred<void>;} export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; const defaultSessionLoadTimeout = Duration.seconds(90);const defaultSessionLoadReplayIdleGap = Duration.seconds(2); export interface AcpSpawnInput {  readonly command: string;  readonly args: ReadonlyArray<string>;  readonly cwd?: string;  readonly env?: NodeJS.ProcessEnv;} export interface AcpSessionRuntimeOptions {  readonly spawn: AcpSpawnInput;  readonly cwd: string;  readonly resumeSessionId?: string;  readonly sessionLoadTimeout?: Duration.Input;  readonly sessionLoadReplayIdleGap?: Duration.Input;  readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];  readonly clientInfo: {    readonly name: string;    readonly version: string;  };  readonly authMethodId: string;  readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;  readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;  readonly protocolLogging?: {    readonly logIncoming?: boolean;    readonly logOutgoing?: boolean;    readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;  };} export interface AcpSessionRequestLogEvent {  readonly method: string;  readonly payload: unknown;  readonly status: "started" | "succeeded" | "failed";  readonly result?: unknown;  readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;} export interface AcpSessionRuntimeStartResult {  readonly sessionId: string;  readonly initializeResult: EffectAcpSchema.InitializeResponse;  readonly sessionSetupResult:    | EffectAcpSchema.LoadSessionResponse    | EffectAcpSchema.NewSessionResponse    | EffectAcpSchema.ResumeSessionResponse;  readonly modelConfigId: string | undefined;} export class AcpSessionRuntime extends Context.Service<  AcpSessionRuntime,  {    /**     * Registers a handler for `session/request_permission`.     * @see https://agentclientprotocol.com/protocol/schema#session/request_permission     */    readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"];    /**     * Registers a handler for `session/elicitation`.     * @see https://agentclientprotocol.com/protocol/schema#session/elicitation     */    readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"];    /**     * Registers a handler for `fs/read_text_file`.     * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file     */    readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"];    /**     * Registers a handler for `fs/write_text_file`.     * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file     */    readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"];    /**     * Registers a handler for `terminal/create`.     * @see https://agentclientprotocol.com/protocol/schema#terminal/create     */    readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"];    /**     * Registers a handler for `terminal/output`.     * @see https://agentclientprotocol.com/protocol/schema#terminal/output     */    readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"];    /**     * Registers a handler for `terminal/wait_for_exit`.     * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit     */    readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"];    /**     * Registers a handler for `terminal/kill`.     * @see https://agentclientprotocol.com/protocol/schema#terminal/kill     */    readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"];    /**     * Registers a handler for `terminal/release`.     * @see https://agentclientprotocol.com/protocol/schema#terminal/release     */    readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"];    /**     * Registers a handler for `session/update`.     * @see https://agentclientprotocol.com/protocol/schema#session/update     */    readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"];    /**     * Registers a handler for `session/elicitation/complete`.     * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete     */    readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"];    /**     * Registers a fallback extension request handler.     * @see https://agentclientprotocol.com/protocol/extensibility     */    readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"];    /**     * Registers a fallback extension notification handler.     * @see https://agentclientprotocol.com/protocol/extensibility     */    readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"];    /**     * Registers a typed extension request handler.     * @see https://agentclientprotocol.com/protocol/extensibility     */    readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"];    /**     * Registers a typed extension notification handler.     * @see https://agentclientprotocol.com/protocol/extensibility     */    readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"];    /**     * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session.     * Concurrent calls share the same in-flight startup and a failed startup may be retried.     */    readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;    /** Stream of parsed ACP session events emitted after startup. */    readonly getEvents: () => Stream.Stream<AcpSessionRuntimeEvent, never>;    /** Waits until the current event consumer has processed every queued event. */    readonly drainEvents: Effect.Effect<void>;    /** Latest mode state observed from session setup and `session/update` notifications. */    readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;    /** Latest configuration options observed from session setup and configuration writes. */    readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;    /**     * Sends a prompt turn to the active session.     * @see https://agentclientprotocol.com/protocol/schema#session/prompt     */    readonly prompt: (      payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,    ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;    /**     * Sends a real ACP `session/cancel` notification for the active session.     * @see https://agentclientprotocol.com/protocol/schema#session/cancel     */    readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>;    /**     * Selects the active mode through the negotiated `mode` configuration option.     * This is a no-op when the requested mode is already active.     * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option     */    readonly setMode: (      modeId: string,    ) => Effect.Effect<EffectAcpSchema.SetSessionModeResponse, EffectAcpErrors.AcpError>;    /**     * Updates a session configuration option and the runtime configuration snapshot.     * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option     */    readonly setConfigOption: (      configId: string,      value: string | boolean,    ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>;    /**     * Selects the base model through the negotiated model configuration option.     * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option     */    readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;    /**     * Selects the active model through the unstable ACP `session/set_model` capability.     * @see https://agentclientprotocol.com/protocol/schema#session/set_model     */    readonly setSessionModel: (      modelId: string,    ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>;    /**     * Sends a generic ACP extension request and records it through the request logger.     * @see https://agentclientprotocol.com/protocol/extensibility     */    readonly request: (      method: string,      payload: unknown,    ) => Effect.Effect<unknown, EffectAcpErrors.AcpError>;    /**     * Sends a generic ACP extension notification.     * @see https://agentclientprotocol.com/protocol/extensibility     */    readonly notify: (      method: string,      payload: unknown,    ) => Effect.Effect<void, EffectAcpErrors.AcpError>;  }>()("t3/provider/acp/AcpSessionRuntime") {} interface AcpStartedState extends AcpSessionRuntimeStartResult {} type AcpStartState =  | { readonly _tag: "NotStarted" }  | {      readonly _tag: "Starting";      readonly deferred: Deferred.Deferred<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;    }  | { readonly _tag: "Started"; readonly result: AcpStartedState }; interface AcpAssistantSegmentState {  readonly nextSegmentIndex: number;  readonly activeItemId?: string;} interface EnsureActiveAssistantSegmentResult {  readonly itemId: string;  readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;} export const make = (  options: AcpSessionRuntimeOptions,): Effect.Effect<  AcpSessionRuntime["Service"],  EffectAcpErrors.AcpError,  ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope> =>  Effect.gen(function* () {    const crypto = yield* Crypto.Crypto;    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;    const runtimeScope = yield* Scope.Scope;    const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>();    const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined);    const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallState>());    const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe(      Effect.mapError(        (cause) =>          new EffectAcpErrors.AcpTransportError({            detail: "Failed to generate an ACP assistant item runtime identifier.",            cause,          }),      ),    );    const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 });    const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));    const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });    const promptSerializationSemaphore = yield* Semaphore.make(1);    const activePromptFiberRef = yield* Ref.make<      Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>>    >(Option.none());    const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none());     const logRequest = (event: AcpSessionRequestLogEvent) =>      options.requestLogger ? options.requestLogger(event) : Effect.void;     const runLoggedRequest = <A>(      method: string,      payload: unknown,      effect: Effect.Effect<A, EffectAcpErrors.AcpError>,    ): Effect.Effect<A, EffectAcpErrors.AcpError> =>      logRequest({ method, payload, status: "started" }).pipe(        Effect.flatMap(() =>          effect.pipe(            Effect.tap((result) =>              logRequest({                method,                payload,                status: "succeeded",                result,              }),            ),            Effect.onError((cause) =>              logRequest({                method,                payload,                status: "failed",                cause,              }),            ),          ),        ),      );     const spawnCommand = yield* resolveSpawnCommand(      options.spawn.command,      options.spawn.args,      options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {},    );    const child = yield* spawner      .spawn(        ChildProcess.make(spawnCommand.command, spawnCommand.args, {          ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),          ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}),          shell: spawnCommand.shell,        }),      )      .pipe(        Effect.provideService(Scope.Scope, runtimeScope),        Effect.mapError(          (cause) =>            new EffectAcpErrors.AcpSpawnError({              command: options.spawn.command,              cause,            }),        ),      );     const acpContext = yield* Layer.build(      EffectAcpClient.layerChildProcess(child, {        ...(options.protocolLogging?.logIncoming !== undefined          ? { logIncoming: options.protocolLogging.logIncoming }          : {}),        ...(options.protocolLogging?.logOutgoing !== undefined          ? { logOutgoing: options.protocolLogging.logOutgoing }          : {}),        ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}),      }),    ).pipe(Effect.provideService(Scope.Scope, runtimeScope));     const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext));     yield* acp.handleSessionUpdate((notification) =>      Effect.gen(function* () {        const gate = yield* Ref.get(sessionLoadGateRef);        if (Option.isSome(gate) && gate.value.active) {          const lastActivityAtMillis = yield* Clock.currentTimeMillis;          yield* Ref.set(            sessionLoadGateRef,            Option.some({              ...gate.value,              lastActivityAtMillis,            }),          );          return;        }        if (sessionUpdateIsReplay(notification)) {          return;        }        const startState = yield* Ref.get(startStateRef);        // One runtime projects one root ACP session. Child-session updates need        // explicit lineage routing and must never be flattened into this stream.        if (          startState._tag !== "Started" ||          notification.sessionId !== startState.result.sessionId        ) {          return;        }        yield* handleSessionUpdate({          queue: eventQueue,          modeStateRef,          toolCallsRef,          assistantSegmentRef,          assistantItemRuntimeId,          params: notification,        });      }),    );    const initializeClientCapabilities = {      fs: {        readTextFile: false,        writeTextFile: false,        ...options.clientCapabilities?.fs,      },      terminal: options.clientCapabilities?.terminal ?? false,      ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}),      ...(options.clientCapabilities?.elicitation        ? { elicitation: options.clientCapabilities.elicitation }        : {}),      ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}),    } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;     const getStartedState = Effect.gen(function* () {      const state = yield* Ref.get(startStateRef);      if (state._tag === "Started") {        return state.result;      }      return yield* new EffectAcpErrors.AcpTransportError({        detail: "ACP session runtime has not been started",        cause: "ACP session runtime has not been started",      });    });     const validateConfigOptionValue = (      configId: string,      value: string | boolean,    ): Effect.Effect<void, EffectAcpErrors.AcpError> =>      Effect.gen(function* () {        const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId);        if (!configOption) {          return;        }        if (configOption.type === "boolean") {          if (typeof value === "boolean") {            return;          }          return yield* new EffectAcpErrors.AcpRequestError({            code: -32602,            errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`,            data: {              configId: configOption.id,              expectedType: "boolean",              receivedValue: value,            },          });        }        if (typeof value !== "string") {          return yield* new EffectAcpErrors.AcpRequestError({            code: -32602,            errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`,            data: {              configId: configOption.id,              expectedType: "string",              receivedValue: value,            },          });        }        const allowedValues = collectSessionConfigOptionValues(configOption);        if (allowedValues.includes(value)) {          return;        }        return yield* new EffectAcpErrors.AcpRequestError({          code: -32602,          errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`,          data: {            configId: configOption.id,            allowedValues,            receivedValue: value,          },        });      });     const updateConfigOptions = (      response:        | EffectAcpSchema.SetSessionConfigOptionResponse        | EffectAcpSchema.LoadSessionResponse        | EffectAcpSchema.NewSessionResponse        | EffectAcpSchema.ResumeSessionResponse,    ): Effect.Effect<void> => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response));     const updateCurrentModeId = (modeId: string): Effect.Effect<void> =>      Ref.update(modeStateRef, (current) =>        current ? { ...current, currentModeId: modeId } : current,      );     const setConfigOption = (      configId: string,      value: string | boolean,    ): Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError> =>      validateConfigOptionValue(configId, value).pipe(        Effect.flatMap(() => getStartedState),        Effect.flatMap((started) =>          Ref.get(configOptionsRef).pipe(

Lines 1–500 of 1005.