1344 lines · 46.0 KB · TypeScript
RawDownload
import { randomUUID } from "node:crypto"; import {  EventId,  type ProviderRuntimeEvent,  type ProviderSession,  RuntimeItemId,  RuntimeRequestId,  ThreadId,  type ToolLifecycleItemType,  TurnId,  type UserInputQuestion,} from "@t3tools/contracts";import { Cause, Effect, Layer, Queue, Stream } from "effect";import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { resolveAttachmentPath } from "../../attachmentStore.ts";import { ServerConfig } from "../../config.ts";import { ServerSettingsService } from "../../serverSettings.ts";import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";import {  ProviderAdapterProcessError,  ProviderAdapterRequestError,  ProviderAdapterSessionClosedError,  ProviderAdapterSessionNotFoundError,  ProviderAdapterValidationError,} from "../Errors.ts";import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";import {  buildOpenCodePermissionRules,  connectToOpenCodeServer,  createOpenCodeSdkClient,  openCodeQuestionId,  parseOpenCodeModelSlug,  toOpenCodeFileParts,  toOpenCodePermissionReply,  toOpenCodeQuestionAnswers,  type OpenCodeServerConnection,} from "../opencodeRuntime.ts"; const PROVIDER = "opencode" as const; interface OpenCodeTurnSnapshot {  readonly id: TurnId;  readonly items: Array<unknown>;} interface OpenCodeSessionContext {  session: ProviderSession;  readonly client: OpencodeClient;  readonly server: OpenCodeServerConnection;  readonly directory: string;  readonly openCodeSessionId: string;  readonly pendingPermissions: Map<string, PermissionRequest>;  readonly pendingQuestions: Map<string, QuestionRequest>;  readonly messageRoleById: Map<string, "user" | "assistant">;  readonly partById: Map<string, Part>;  readonly emittedTextByPartId: Map<string, string>;  readonly completedAssistantPartIds: Set<string>;  readonly turns: Array<OpenCodeTurnSnapshot>;  activeTurnId: TurnId | undefined;  activeAgent: string | undefined;  activeVariant: string | undefined;  stopped: boolean;  readonly eventsAbortController: AbortController;} export interface OpenCodeAdapterLiveOptions {  readonly nativeEventLogPath?: string;  readonly nativeEventLogger?: EventNdjsonLogger;} function nowIso(): string {  return new Date().toISOString();} function isProviderAdapterRequestError(cause: unknown): cause is ProviderAdapterRequestError {  return (    typeof cause === "object" &&    cause !== null &&    "_tag" in cause &&    cause._tag === "ProviderAdapterRequestError"  );} function buildEventBase(input: {  readonly threadId: ThreadId;  readonly turnId?: TurnId | undefined;  readonly itemId?: string | undefined;  readonly requestId?: string | undefined;  readonly createdAt?: string | undefined;  readonly raw?: unknown;}): Pick<  ProviderRuntimeEvent,  "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw"> {  return {    eventId: EventId.make(randomUUID()),    provider: PROVIDER,    threadId: input.threadId,    createdAt: input.createdAt ?? nowIso(),    ...(input.turnId ? { turnId: input.turnId } : {}),    ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),    ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}),    ...(input.raw !== undefined      ? {          raw: {            source: "opencode.sdk.event",            payload: input.raw,          },        }      : {}),  };} function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {  const normalized = toolName.toLowerCase();  if (normalized.includes("bash") || normalized.includes("command")) {    return "command_execution";  }  if (    normalized.includes("edit") ||    normalized.includes("write") ||    normalized.includes("patch") ||    normalized.includes("multiedit")  ) {    return "file_change";  }  if (normalized.includes("web")) {    return "web_search";  }  if (normalized.includes("mcp")) {    return "mcp_tool_call";  }  if (normalized.includes("image")) {    return "image_view";  }  if (    normalized.includes("task") ||    normalized.includes("agent") ||    normalized.includes("subtask")  ) {    return "collab_agent_tool_call";  }  return "dynamic_tool_call";} function mapPermissionToRequestType(  permission: string,): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" {  switch (permission) {    case "bash":      return "command_execution_approval";    case "read":      return "file_read_approval";    case "edit":      return "file_change_approval";    default:      return "unknown";  }} function mapPermissionDecision(reply: "once" | "always" | "reject"): string {  switch (reply) {    case "once":      return "accept";    case "always":      return "acceptForSession";    case "reject":    default:      return "decline";  }} function resolveTurnSnapshot(  context: OpenCodeSessionContext,  turnId: TurnId,): OpenCodeTurnSnapshot {  const existing = context.turns.find((turn) => turn.id === turnId);  if (existing) {    return existing;  }   const created: OpenCodeTurnSnapshot = { id: turnId, items: [] };  context.turns.push(created);  return created;} function appendTurnItem(  context: OpenCodeSessionContext,  turnId: TurnId | undefined,  item: unknown,): void {  if (!turnId) {    return;  }  resolveTurnSnapshot(context, turnId).items.push(item);} function ensureSessionContext(  sessions: ReadonlyMap<ThreadId, OpenCodeSessionContext>,  threadId: ThreadId,): OpenCodeSessionContext {  const session = sessions.get(threadId);  if (!session) {    throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId });  }  if (session.stopped) {    throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId });  }  return session;} function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray<UserInputQuestion> {  return request.questions.map((question, index) => ({    id: openCodeQuestionId(index, question),    header: question.header,    question: question.question,    options: question.options.map((option) => ({      label: option.label,      description: option.description,    })),    ...(question.multiple ? { multiSelect: true } : {}),  }));} function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" {  return part?.type === "reasoning" ? "reasoning_text" : "assistant_text";} function textFromPart(part: Part): string | undefined {  switch (part.type) {    case "text":    case "reasoning":      return part.text;    default:      return undefined;  }} function commonPrefixLength(left: string, right: string): number {  let index = 0;  while (index < left.length && index < right.length && left[index] === right[index]) {    index += 1;  }  return index;} function suffixPrefixOverlap(text: string, delta: string): number {  const maxLength = Math.min(text.length, delta.length);  for (let length = maxLength; length > 0; length -= 1) {    if (text.endsWith(delta.slice(0, length))) {      return length;    }  }  return 0;} function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string {  if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) {    return previousText;  }  return nextText;} export function mergeOpenCodeAssistantText(  previousText: string | undefined,  nextText: string,): {  readonly latestText: string;  readonly deltaToEmit: string;} {  const latestText = resolveLatestAssistantText(previousText, nextText);  return {    latestText,    deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)),  };} export function appendOpenCodeAssistantTextDelta(  previousText: string,  delta: string,): {  readonly nextText: string;  readonly deltaToEmit: string;} {  const deltaToEmit = delta.slice(suffixPrefixOverlap(previousText, delta));  return {    nextText: previousText + deltaToEmit,    deltaToEmit,  };} function isoFromEpochMs(value: number | undefined): string | undefined {  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {    return undefined;  }  return new Date(value).toISOString();} function messageRoleForPart(  context: OpenCodeSessionContext,  part: Pick<Part, "messageID" | "type">,): "assistant" | "user" | undefined {  const known = context.messageRoleById.get(part.messageID);  if (known) {    return known;  }  return part.type === "tool" ? "assistant" : undefined;} function detailFromToolPart(part: Extract<Part, { type: "tool" }>): string | undefined {  switch (part.state.status) {    case "completed":      return part.state.output;    case "error":      return part.state.error;    case "running":      return part.state.title;    default:      return undefined;  }} function toolStateCreatedAt(part: Extract<Part, { type: "tool" }>): string | undefined {  switch (part.state.status) {    case "running":      return isoFromEpochMs(part.state.time.start);    case "completed":    case "error":      return isoFromEpochMs(part.state.time.end);    default:      return undefined;  }} function sessionErrorMessage(error: unknown): string {  if (!error || typeof error !== "object") {    return "OpenCode session failed.";  }  const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null;  const message = data && "message" in data ? data.message : null;  return typeof message === "string" && message.trim().length > 0    ? message    : "OpenCode session failed.";} function updateProviderSession(  context: OpenCodeSessionContext,  patch: Partial<ProviderSession>,  options?: {    readonly clearActiveTurnId?: boolean;    readonly clearLastError?: boolean;  },): ProviderSession {  const nextSession = {    ...context.session,    ...patch,    updatedAt: nowIso(),  } as ProviderSession & Record<string, unknown>;  const mutableSession = nextSession as Record<string, unknown>;  if (options?.clearActiveTurnId) {    delete mutableSession.activeTurnId;  }  if (options?.clearLastError) {    delete mutableSession.lastError;  }  context.session = nextSession;  return nextSession;} async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise<void> {  context.stopped = true;  context.eventsAbortController.abort();  try {    await context.client.session      .abort({ sessionID: context.openCodeSessionId })      .catch(() => undefined);  } catch {}  context.server.close();} export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {  return Layer.effect(    OpenCodeAdapter,    Effect.gen(function* () {      const serverConfig = yield* ServerConfig;      const serverSettings = yield* ServerSettingsService;      const services = yield* Effect.context<never>();      const nativeEventLogger =        _options?.nativeEventLogger ??        (_options?.nativeEventLogPath !== undefined          ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, {              stream: "native",            })          : undefined);      const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();      const sessions = new Map<ThreadId, OpenCodeSessionContext>();       const emit = (event: ProviderRuntimeEvent) =>        Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);      const emitPromise = (event: ProviderRuntimeEvent) =>        emit(event).pipe(Effect.runPromiseWith(services));      const writeNativeEventPromise = (        threadId: ThreadId,        event: {          readonly observedAt: string;          readonly event: Record<string, unknown>;        },      ) =>        (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe(          Effect.runPromiseWith(services),        );      const writeNativeEventBestEffort = (        threadId: ThreadId,        event: {          readonly observedAt: string;          readonly event: Record<string, unknown>;        },      ) => writeNativeEventPromise(threadId, event).catch(() => undefined);       const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => {        if (context.stopped) {          return;        }        context.stopped = true;        sessions.delete(context.session.threadId);        context.server.close();        const turnId = context.activeTurnId;        void emitPromise({          ...buildEventBase({ threadId: context.session.threadId, turnId }),          type: "runtime.error",          payload: {            message,            class: "transport_error",          },        }).catch(() => undefined);        void emitPromise({          ...buildEventBase({ threadId: context.session.threadId, turnId }),          type: "session.exited",          payload: {            reason: message,            recoverable: false,            exitKind: "error",          },        }).catch(() => undefined);      };       /** Emit content.delta and item.completed events for an assistant text part. */      const emitAssistantTextDelta = async (        context: OpenCodeSessionContext,        part: Part,        turnId: TurnId | undefined,        raw: unknown,      ): Promise<void> => {        const text = textFromPart(part);        if (text === undefined) {          return;        }        const previousText = context.emittedTextByPartId.get(part.id);        const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text);        context.emittedTextByPartId.set(part.id, latestText);        if (latestText !== text) {          context.partById.set(            part.id,            (part.type === "text" || part.type === "reasoning"              ? { ...part, text: latestText }              : part) satisfies Part,          );        }        if (deltaToEmit.length > 0) {          await emitPromise({            ...buildEventBase({              threadId: context.session.threadId,              turnId,              itemId: part.id,              createdAt:                part.type === "text" || part.type === "reasoning"                  ? isoFromEpochMs(part.time?.start)                  : undefined,              raw,            }),            type: "content.delta",            payload: {              streamKind: resolveTextStreamKind(part),              delta: deltaToEmit,            },          });        }         if (          part.type === "text" &&          part.time?.end !== undefined &&          !context.completedAssistantPartIds.has(part.id)        ) {          context.completedAssistantPartIds.add(part.id);          await emitPromise({            ...buildEventBase({              threadId: context.session.threadId,              turnId,

Lines 1–500 of 1344.