3866 lines · 142.2 KB · TypeScript
RawDownload
import {  EventId,  type OpenCodeSettings,  ProviderDriverKind,  ProviderInstanceId,  type ProviderRuntimeEvent,  type ProviderSendTurnInput,  type ProviderSession,  RuntimeItemId,  RuntimeRequestId,  ThreadId,  type ToolLifecycleItemType,  type TurnTokenUsage,  TurnId,  type UserInputQuestion,} from "@t3tools/contracts";import * as Cause from "effect/Cause";import * as Crypto from "effect/Crypto";import * as DateTime from "effect/DateTime";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Fiber from "effect/Fiber";import * as Path from "effect/Path";import * as Queue from "effect/Queue";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream";import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts";import { ServerConfig } from "../../config.ts";import * as McpProviderSession from "../../mcp/McpProviderSession.ts";import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";import {  ProviderAdapterProcessError,  ProviderAdapterRequestError,  ProviderAdapterSessionClosedError,  ProviderAdapterSessionNotFoundError,  ProviderAdapterValidationError,} from "../Errors.ts";import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";import {  buildOpenCodePermissionRules,  OpenCodeRuntime,  OpenCodeRuntimeError,  openCodeQuestionId,  openCodeRuntimeErrorDetail,  parseOpenCodeModelSlug,  runOpenCodeSdk,  toOpenCodeFileParts,  toOpenCodePermissionReply,  toOpenCodeQuestionAnswers,  type OpenCodeServerConnection,} from "../opencodeRuntime.ts";import * as Option from "effect/Option"; const PROVIDER = ProviderDriverKind.make("opencode"); /** * Version tag stamped into the OpenCode resume cursor. Bump if the cursor * shape changes so stale-shaped cursors written by older builds are ignored * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). */const OPENCODE_RESUME_VERSION = 1 as const; /** * Decode a persisted resume cursor into the upstream `ses_…` id. Anything * that isn't a current-version cursor with a non-empty id means "no resume" * rather than an error. Re-adopting the session id IS the resume mechanism — * OpenCode scopes a conversation's history by session id. */function parseOpenCodeResume(raw: unknown): { readonly sessionId: string } | undefined {  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {    return undefined;  }  const record = raw as Record<string, unknown>;  if (record.schemaVersion !== OPENCODE_RESUME_VERSION) {    return undefined;  }  if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) {    return undefined;  }  return { sessionId: record.sessionId.trim() };} /** * Whether an error definitively reports a missing session. Only a confirmed * miss may silently start a fresh session; any other failure (the SDK client * is `throwOnError: true`, so `session.get` rejects on every non-2xx) must * propagate, or a transient blip resets a live thread to an empty one — the * #3604 silent context loss. Decides on structured signals only, never free * text: a numeric 404 or the exact `NotFoundError` name, found via a bounded walk * over `cause`/`body`/`error`/`data`. An explicit non-404 status seals its * subtree so a wrapped "NotFound" name can't reclassify a real failure. * Exported for unit testing. */export function isOpenCodeNotFound(cause: unknown): boolean {  const seen = new Set<unknown>();  const queue: Array<unknown> = [cause];  for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) {    const node = queue.shift();    if (node === null || typeof node !== "object" || seen.has(node)) {      continue;    }    seen.add(node);    const record = node as Record<string, unknown>;     const response = record.response;    const statuses = [      record.status,      record.statusCode,      response !== null && typeof response === "object"        ? (response as { readonly status?: unknown }).status        : undefined,    ].filter((status): status is number => typeof status === "number");    if (statuses.includes(404)) {      return true;    }    if (statuses.length > 0) {      continue;    }     const name = record.name;    if (typeof name === "string" && name.toLowerCase() === "notfounderror") {      return true;    }     for (const key of ["cause", "body", "error", "data"] as const) {      if (record[key] !== undefined) {        queue.push(record[key]);      }    }  }  return false;} /** * Whether two directory spellings name the same location. Raw string * equality misreads a trailing slash, `.`/`..` segment, or symlinked cwd * (macOS `/tmp` → `/private/tmp`) as a cwd change, needlessly forking the * session on every resume. Lexically equal paths short-circuit; otherwise * both sides go through `realPath`, each falling back to its lexical form * on failure (deleted directory, external-server path) — so the probe can * only widen matches, never split them. Takes the services as arguments so * adapter methods stay service-free. Exported for unit testing. */export function isSameOpenCodeDirectory(  fileSystem: FileSystem.FileSystem,  path: Path.Path,  left: string,  right: string,): Effect.Effect<boolean> {  const lexicalLeft = path.resolve(left);  const lexicalRight = path.resolve(right);  if (lexicalLeft === lexicalRight) {    return Effect.succeed(true);  }  const canonicalize = (lexical: string) =>    fileSystem.realPath(lexical).pipe(Effect.orElseSucceed(() => lexical));  return Effect.zipWith(    canonicalize(lexicalLeft),    canonicalize(lexicalRight),    (canonicalLeft, canonicalRight) => canonicalLeft === canonicalRight,  );} interface OpenCodeTurnSnapshot {  readonly id: TurnId;  readonly items: Array<unknown>;} type OpenCodeSubscribedEvent =  Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>> extends {    readonly stream: AsyncIterable<infer TEvent>;  }    ? TEvent    : never; type OpenCodeSessionStatusEvent = Extract<  OpenCodeSubscribedEvent,  { readonly type: "session.status" }>; const OpenCodeSessionStatusMap = Schema.Record(  Schema.String,  Schema.Struct({ type: Schema.String }),);const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessionStatusMap); interface OpenCodeCancellation {  readonly turnId: TurnId | undefined;  readonly acknowledgment: Deferred.Deferred<void>;  readonly completion: Deferred.Deferred<void, ProviderAdapterRequestError>;  acknowledged?: boolean;  turnSettled?: boolean;  deferredIdleEvent?: OpenCodeSessionStatusEvent;} interface OpenCodeIdleReconciliation {  readonly turnId: TurnId;  readonly promptGeneration: number;  raw: unknown;  warned: boolean;  dirty: boolean;  fiber?: Fiber.Fiber<void, never>;} interface OpenCodePromptAdmission {  readonly generation: number;  readonly turnId: TurnId;  readonly messageId: string;  readonly priorAwaitingBusy: boolean;  readonly priorIdle: { readonly turnId: TurnId; readonly raw: unknown } | undefined;  idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined;  idleObservedAfterMessage: boolean;  messageObserved: boolean;  busyObserved: boolean;  idleStatusConfirmations: number;  accepted: boolean;  cancelled: boolean;  readonly acceptance: Deferred.Deferred<void>;  readonly submissionSettled: Deferred.Deferred<void>;  promptFiber?: Fiber.Fiber<void, ProviderAdapterRequestError>;  recoveryFiber?: Fiber.Fiber<void, never>;  recoveryRaw: unknown;} type OpenCodeTerminalRequestEvent = Extract<  OpenCodeSubscribedEvent,  {    readonly type: "permission.replied" | "question.replied" | "question.rejected";  }>; type OpenCodeAskedRequestEvent = Extract<  OpenCodeSubscribedEvent,  { readonly type: "permission.asked" | "question.asked" }>; type OpenCodeRoutedRequestEvent = OpenCodeAskedRequestEvent | OpenCodeTerminalRequestEvent; interface OpenCodeRequestRelationRetry {  warned: boolean;  fiber?: Fiber.Fiber<void, never>;} interface OpenCodePendingRequestRecovery {  warned: boolean;  rerun: boolean;} function trimText(value: string | undefined | null): string | undefined {  const trimmed = value?.trim();  return trimmed && trimmed.length > 0 ? trimmed : undefined;} function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined {  const properties = "properties" in event ? event.properties : undefined;  if (!properties || typeof properties !== "object") {    return undefined;  }   const sessionID = (properties as { readonly sessionID?: unknown }).sessionID;  const sessionIDFromProperties = typeof sessionID === "string" ? sessionID : undefined;  if (sessionIDFromProperties) {    return sessionIDFromProperties;  }   const info = (properties as { readonly info?: { readonly id?: unknown } }).info;  return info && typeof info.id === "string" ? info.id : undefined;} function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | undefined {  if (event.type !== "session.updated") {    return undefined;  }   const title = trimText(event.properties.info.title);  // OpenCode mints a placeholder title at session.create when no title was  // provided, and re-emits it on every `session.updated`. Mirroring it would  // overwrite the thread's real title (openCodeEventSessionTitle feeds the  // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated  // placeholders so the thread isn't locked onto them.  if (!title || isOpenCodeDefaultTitle(title)) {    return undefined;  }   return title;} function isOpenCodeAbortError(error: unknown): boolean {  return (    typeof error === "object" &&    error !== null &&    "name" in error &&    error.name === "MessageAbortedError"  );} function isOpenCodeChildRequestEvent(event: OpenCodeSubscribedEvent): boolean {  switch (event.type) {    case "permission.asked":    case "permission.replied":    case "question.asked":    case "question.replied":    case "question.rejected":      return true;    default:      return false;  }} const OPENCODE_DEFAULT_TITLE_PATTERN =  /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; function isOpenCodeDefaultTitle(title: string): boolean {  return OPENCODE_DEFAULT_TITLE_PATTERN.test(title);} type OpenCodeTextPart = Extract<Part, { readonly type: "text" | "reasoning" }>; type OpenCodeTextPartState = Pick<OpenCodeTextPart, "id" | "messageID" | "type" | "time"> & {  text: string | undefined;  emittedText: string | undefined;  completed: boolean;}; type OpenCodeStepUsage = Pick<Extract<Part, { readonly type: "step-finish" }>, "id" | "tokens">; interface OpenCodeSessionContext {  session: ProviderSession;  readonly client: OpencodeClient;  readonly server: OpenCodeServerConnection;  readonly directory: string;  readonly openCodeSessionId: string;  readonly relatedSessionIds: Set<string>;  readonly resolvedRequestIds: Set<string>;  readonly autoRepliedRequestIds: Set<string>;  readonly emittedTerminalRequestIds: Set<string>;  readonly requestRelationRetries: Map<string, OpenCodeRequestRelationRetry>;  readonly pendingPermissions: Map<string, PermissionRequest>;  readonly pendingQuestions: Map<string, QuestionRequest>;  readonly messageRoleById: Map<string, "user" | "assistant">;  // OpenCode permits edits to completed parts. Keep text for snapshot comparison  // until native removal or session teardown, but do not retain other part payloads.  readonly textPartsByMessageId: Map<string, Map<string, OpenCodeTextPartState>>;  turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined;  activeTurnId: TurnId | undefined;  activeAgent: string | undefined;  activeVariant: string | undefined;  cancellation: OpenCodeCancellation | undefined;  interruptedTurnId: TurnId | undefined;  reconcileIdleStatus: boolean;  awaitingBusyAfterInterruption: boolean;  pendingIdleReconciliation: OpenCodeIdleReconciliation | undefined;  pendingRequestRecovery: OpenCodePendingRequestRecovery | undefined;  promptGeneration: number;  promptAdmission: OpenCodePromptAdmission | undefined;  readonly promptSemaphore: Semaphore.Semaphore;  readonly firstConnection: Deferred.Deferred<void, ProviderAdapterRequestError>;  /**   * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`.   * The session lifecycle is owned by `sessionScope`; this Ref exists only   * so concurrent callers can race the transition safely via `getAndSet`.   */  readonly stopped: Ref.Ref<boolean>;  /**   * Sole lifecycle handle for the session. Closing this scope:   *   - aborts the `AbortController` registered as a finalizer   *     (cancels the in-flight `event.subscribe` fetch),   *   - interrupts the event-pump and server-exit fibers forked   *     via `Effect.forkIn(sessionScope)`,   *   - tears down the OpenCode server process for scope-owned servers.   */  readonly sessionScope: Scope.Closeable;} interface OpenCodeTurnTokenUsageAccumulator {  readonly partIds: Set<string>;  readonly promptMessageIds: Set<string>;  readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">;  // Native removal does not undo usage. Keep unresolved counts until this turn settles.  readonly unresolvedStepsByMessageId: Map<string, Map<string, OpenCodeStepUsage>>;  inputTokens: number;  cachedInputTokens: number;  cacheCreationTokens: number;  outputTokens: number;  reasoningTokens: number;  complete: boolean;  hasSubagents: boolean;} function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator {  return {    partIds: new Set(),    promptMessageIds: new Set(),    assistantOwnershipByMessageId: new Map(),    unresolvedStepsByMessageId: new Map(),    inputTokens: 0,    cachedInputTokens: 0,    cacheCreationTokens: 0,    outputTokens: 0,    reasoningTokens: 0,    complete: true,    hasSubagents: false,  };} function accumulateOpenCodeStepUsage(  accumulator: OpenCodeTurnTokenUsageAccumulator,  part: OpenCodeStepUsage,): void {  if (accumulator.partIds.has(part.id)) return;  accumulator.partIds.add(part.id);  accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write;  accumulator.cachedInputTokens += part.tokens.cache.read;  accumulator.cacheCreationTokens += part.tokens.cache.write;  accumulator.outputTokens += part.tokens.output + part.tokens.reasoning;  accumulator.reasoningTokens += part.tokens.reasoning;} function takeOpenCodeTurnTokenUsage(  context: OpenCodeSessionContext,  complete: boolean,): TurnTokenUsage {  const usage = context.turnTokenUsage;  context.turnTokenUsage = undefined;  if (!usage || usage.partIds.size === 0) {    return {      usageStatus: "unavailable",      usageScope: "main_agent",      hasSubagents: usage?.hasSubagents ?? false,    };  }  return {    usageStatus:      complete && usage.complete && usage.unresolvedStepsByMessageId.size === 0        ? "complete"        : "partial",    usageScope: "main_agent",    inputTokens: usage.inputTokens,    cachedInputTokens: usage.cachedInputTokens,    cacheCreationTokens: usage.cacheCreationTokens,    outputTokens: usage.outputTokens,    reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens),    hasSubagents: usage.hasSubagents,  };} export interface OpenCodeAdapterLiveOptions {  readonly instanceId?: ProviderInstanceId;  readonly environment?: NodeJS.ProcessEnv;  readonly nativeEventLogPath?: string;  readonly nativeEventLogger?: EventNdjsonLogger;} const nowIso = Effect.map(DateTime.now, DateTime.formatIso); /** * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call * sites pipe through this in `Effect.mapError` so they never build the error * shape by hand. */const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestError =>  new ProviderAdapterRequestError({    provider: PROVIDER,    method: cause.operation,    detail: cause.detail,    cause: cause.cause,  }); /** * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The * typed cause is usually an `OpenCodeRuntimeError` (from {@link runOpenCodeSdk}), * in which case we preserve its `detail`; otherwise we fall back to * {@link openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). */const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError =>  new ProviderAdapterProcessError({    provider: PROVIDER,    threadId,    detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause),    cause,  }); type EventBaseInput = {  readonly threadId: ThreadId;  readonly turnId?: TurnId | undefined;  readonly itemId?: string | undefined;  readonly requestId?: string | undefined;  readonly createdAt?: string | undefined;  readonly raw?: unknown;};

Lines 1–500 of 3866.