Part IV · Five harnesses, one product modelACP, Cursor, and Grok
Chapter 18source checked

ACP transport, Cursor, and Grok

A shared JSON-RPC runtime gives Cursor and Grok one transport skeleton, while their startup, mode/model, extension, interruption, and rollback semantics deliberately diverge.

What this chapter resolves
  • Trace an ACP child from stdio JSON-RPC through a normalized hot runtime event.
  • Separate negotiated ACP schema affordances from the product adapter’s declared SPI capability.
  • Compare Cursor’s extensions and local transcript rollback with Grok’s XAI extensions and explicitly unsupported native rollback.
  • Explain why steering and interruption need provider-specific race handling.

Cursor and Grok are not two copies of a generic chat API. Both launch an Agent Client Protocol (ACP) child over stdio and reuse T3’s ACP session runtime; after that shared transport, their adapter code makes different promises. Those differences matter more than the shared method names when a meta-harness decides whether a turn can be resumed, steered, cancelled, or rolled back.

This chapter uses ACP capability in its protocol sense only: the upstream initialization response can describe optional operations such as session load, fork, resume, or model switching. It does not mean T3 exposes a dynamic capability-negotiation surface to its own callers. The inspected T3 ProviderAdapter SPI declares only sessionModelSwitch; it is reported as in-session by these adapters. Do not turn the comparison below into a claim that every row is a negotiated runtime feature.

The reusable ACP machine

packages/effect-acp is a typed ACP JSON-RPC client/server bridge. It owns a child process stdio transport, routes agent requests back to registered client handlers, and exposes typed calls for initialization, authentication, session lifecycle, prompt, cancel, configuration, and extension messages. AcpSessionRuntime puts the operational policy on top: it spawns the child inside a scope, serializes prompts, holds the active prompt fiber, parses session/update notifications, and gives adapters a queue of normalized ACP observations.

packages/effect-acp/src/client.ts:40–134 ↗verbatim · typescript · 7df29bfb
  {
    readonly raw: AcpClientRaw;
    readonly agent: {
      /**
       * Initializes the ACP session and negotiates capabilities.
       * @see https://agentclientprotocol.com/protocol/schema#initialize
       */
      readonly initialize: (
        payload: AcpSchema.InitializeRequest,
      ) => Effect.Effect<AcpSchema.InitializeResponse, AcpError.AcpError>;
      /**
       * Performs ACP authentication when the agent requires it.
       * @see https://agentclientprotocol.com/protocol/schema#authenticate
       */
      readonly authenticate: (
        payload: AcpSchema.AuthenticateRequest,
      ) => Effect.Effect<AcpSchema.AuthenticateResponse, AcpError.AcpError>;
      /**
       * Logs out the current ACP identity.
       * @see https://agentclientprotocol.com/protocol/schema#logout
       */
      readonly logout: (
        payload: AcpSchema.LogoutRequest,
      ) => Effect.Effect<AcpSchema.LogoutResponse, AcpError.AcpError>;
      /**
       * Starts a new ACP session.
       * @see https://agentclientprotocol.com/protocol/schema#session/new
       */
      readonly createSession: (
        payload: AcpSchema.NewSessionRequest,
      ) => Effect.Effect<AcpSchema.NewSessionResponse, AcpError.AcpError>;
      /**
       * Loads a previously saved ACP session.
       * @see https://agentclientprotocol.com/protocol/schema#session/load
       */
      readonly loadSession: (
        payload: AcpSchema.LoadSessionRequest,
      ) => Effect.Effect<AcpSchema.LoadSessionResponse, AcpError.AcpError>;
      /**
       * Lists available ACP sessions.
       * @see https://agentclientprotocol.com/protocol/schema#session/list
       */
      readonly listSessions: (
        payload: AcpSchema.ListSessionsRequest,
      ) => Effect.Effect<AcpSchema.ListSessionsResponse, AcpError.AcpError>;
      /**
       * Forks an ACP session.
       * @see https://agentclientprotocol.com/protocol/schema#session/fork
       */
      readonly forkSession: (
        payload: AcpSchema.ForkSessionRequest,
      ) => Effect.Effect<AcpSchema.ForkSessionResponse, AcpError.AcpError>;
      /**
       * Resumes an ACP session.
       * @see https://agentclientprotocol.com/protocol/schema#session/resume
       */
      readonly resumeSession: (
        payload: AcpSchema.ResumeSessionRequest,
      ) => Effect.Effect<AcpSchema.ResumeSessionResponse, AcpError.AcpError>;
      /**
       * Closes an ACP session.
       * @see https://agentclientprotocol.com/protocol/schema#session/close
       */
      readonly closeSession: (
        payload: AcpSchema.CloseSessionRequest,
      ) => Effect.Effect<AcpSchema.CloseSessionResponse, AcpError.AcpError>;
      /**
       * Selects the active model for a session.
       * @see https://agentclientprotocol.com/protocol/schema#session/set_model
       */
      readonly setSessionModel: (
        payload: AcpSchema.SetSessionModelRequest,
      ) => Effect.Effect<AcpSchema.SetSessionModelResponse, AcpError.AcpError>;
      /**
       * Updates a session configuration option.
       * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option
       */
      readonly setSessionConfigOption: (
        payload: AcpSchema.SetSessionConfigOptionRequest,
      ) => Effect.Effect<AcpSchema.SetSessionConfigOptionResponse, AcpError.AcpError>;
      /**
       * Sends a prompt turn to the agent.
       * @see https://agentclientprotocol.com/protocol/schema#session/prompt
       */
      readonly prompt: (
        payload: AcpSchema.PromptRequest,
      ) => Effect.Effect<AcpSchema.PromptResponse, AcpError.AcpError>;
      /**
       * Sends a real ACP `session/cancel` notification.
       * @see https://agentclientprotocol.com/protocol/schema#session/cancel
       */
      readonly cancel: (
        payload: AcpSchema.CancelNotification,
      ) => Effect.Effect<void, AcpError.AcpError>;
    };
Read this as: The ACP client exposes initialize/authenticate, session creation/load/fork/resume, model/config writes, and prompt/cancel. Callback wiring is catalogued with the runtime sources below.
Figure 18.1 · One ACP runtime, many provider-specific meaningsthe solid path is transport; the dashed path is the later durable ingestion boundary
Shared ACP transport and product boundaryDiagram loading

Zoom with the controls, +/, or Ctrl/ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.

Shared ACP transport and product boundary
Text equivalent

An ACP child process communicates via stdio JSON-RPC with the effect-acp client. AcpSessionRuntime initializes it, authenticates it, and either loads a cursor-named session or makes a new one. Parsed updates, permission callbacks, and extension callbacks enter a local adapter queue. The Cursor or Grok adapter turns selected payloads into product runtime events. A separate runtime-ingestion path can turn selected observations into durable orchestration commands and projections. The child process, Deferred callback waits, active prompt fiber, and event queue are not SQLite state.

Figure 18.1. The shared runtime spawns one child, initializes and authenticates it, then creates or loads an ACP session. It parses session updates into a local queue. Cursor or Grok maps those observations and provider extensions into T3 ProviderRuntimeEvent values. Runtime ingestion later decides and commits product-side activity, request, plan, and turn state; the ACP child and its queues remain hot state.

The runtime gives a resumed session special treatment. It asks ACP for session/load with the stored native session id and suppresses replay-shaped updates during an initial bounded gate. It can complete on an RPC response or an idle replay gap, with a default 90-second timeout. That prevents a historical replay from being emitted as a new live turn, but it does not make the child’s upstream history a durable T3 projection.

Cursor: config-driven mode, typed extensions

Cursor spawns cursor-agent … acp, optionally passing a configured endpoint. Its ACP runtime advertises Cursor’s parameterized model-picker capabilities; start requires a non-empty working directory and may pass a persisted native sessionId as a resume input. It can add T3’s MCP server to that ACP session when one exists for the thread.

After ACP startup, the adapter first applies the requested model and then configuration options. It derives a suitable Cursor mode from T3’s runtime/interaction intent: plan/architect aliases for plan work; approval/implement aliases for ordinary work. Those are selection heuristics over Cursor’s returned modes, not universal names guaranteed by ACP. A missing matching mode falls back to another available non-plan mode or the current mode.

apps/server/src/provider/Layers/CursorAdapter.ts:217–294 ↗verbatim · typescript · 496d6f29
function resolveRequestedModeId(input: {
  readonly interactionMode: ProviderInteractionMode | undefined;
  readonly runtimeMode: RuntimeMode;
  readonly modeState: AcpSessionModeState | undefined;
}): string | undefined {
  const modeState = input.modeState;
  if (!modeState) {
    return undefined;
  }
 
  if (input.interactionMode === "plan") {
    return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id;
  }
 
  if (input.runtimeMode === "approval-required") {
    return (
      findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
      findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
      modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
      modeState.currentModeId
    );
  }
 
  return (
    findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
    findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
    modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
    modeState.currentModeId
  );
}
 
function applyRequestedSessionConfiguration<E>(input: {
  readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
  readonly runtimeMode: RuntimeMode;
  readonly interactionMode: ProviderInteractionMode | undefined;
  readonly modelSelection:
    | {
        readonly model: string;
        readonly options?: ReadonlyArray<ProviderOptionSelection> | null | undefined;
      }
    | undefined;
  readonly mapError: (context: {
    readonly cause: import("effect-acp/errors").AcpError;
    readonly method: "session/set_config_option" | "session/set_mode";
  }) => E;
}): Effect.Effect<void, E> {
  return Effect.gen(function* () {
    if (input.modelSelection) {
      yield* applyCursorAcpModelSelection({
        runtime: input.runtime,
        model: input.modelSelection.model,
        selections: input.modelSelection.options,
        mapError: ({ cause }) =>
          input.mapError({
            cause,
            method: "session/set_config_option",
          }),
      });
    }
 
    const requestedModeId = resolveRequestedModeId({
      interactionMode: input.interactionMode,
      runtimeMode: input.runtimeMode,
      modeState: yield* input.runtime.getModeState,
    });
    if (!requestedModeId) {
      return;
    }
 
    yield* input.runtime.setMode(requestedModeId).pipe(
      Effect.mapError((cause) =>
        input.mapError({
          cause,
          method: "session/set_mode",
        }),
      ),
    );
  });
Read this as: Cursor aliases plan/architect, approval/ask, and implement/agent modes, then writes model/configuration through the ACP session runtime.

The native extension surface adds product-specific meaning:

  • cursor/ask_question parks a Deferred answer, emits canonical user-input.requested, and sends a resolved event when T3 responds.
  • cursor/create_plan becomes turn.proposed.completed with plan markdown.
  • cursor/update_todos is normalized into plan updates.
  • standard ACP session/request_permission either auto-selects an allowed option in full-access mode or parks a Deferred approval and emits canonical request events.

The Deferred is deliberately hot. A product request becomes durable only after the later ingestion command succeeds; a restart cannot re-enter an in-process ACP callback by reading the projection alone.

Grok: a different ACP dialect on the same rails

Grok starts grok agent stdio, selects API-key versus cached-token authentication from the environment, and adds the t3code OAuth referrer. It uses the same base runtime lifecycle—initialize, authenticate, load-or-new session, queued updates—but wraps it with XAI prompt-completion handling and its own model resolver. A requested Grok model is applied with unstable ACP session/set_model only when it differs from the session setup’s current model.

apps/server/src/provider/acp/GrokAcpSupport.ts:31–108 ↗verbatim · typescript · d76d1518
 
export function buildGrokAcpSpawnInput(
  grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
  cwd: string,
  environment?: NodeJS.ProcessEnv,
): AcpSessionRuntime.AcpSpawnInput {
  return {
    command: grokSettings?.binaryPath || "grok",
    args: ["agent", "stdio"],
    cwd,
    env: {
      ...environment,
      [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER,
    },
  };
}
 
function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string {
  return environment?.[GROK_API_KEY_ENV]?.trim()
    ? GROK_AUTH_METHOD_API_KEY
    : GROK_AUTH_METHOD_CACHED_TOKEN;
}
 
export const makeGrokAcpRuntime = (
  input: GrokAcpRuntimeInput,
): Effect.Effect<
  AcpSessionRuntime.AcpSessionRuntime["Service"],
  EffectAcpErrors.AcpError,
  Crypto.Crypto | Scope.Scope
> =>
  Effect.gen(function* () {
    const acpContext = yield* Layer.build(
      AcpSessionRuntime.layer({
        ...input,
        spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment),
        authMethodId: resolveGrokAuthMethodId(input.environment),
      }).pipe(
        Layer.provide(
          Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
        ),
      ),
    );
    const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
      Effect.provide(acpContext),
    );
    return yield* makeXAiPromptCompletionRuntime(runtime);
  });
 
export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
  const trimmed = model?.trim();
  const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build";
  return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build";
}
 
export function currentGrokModelIdFromSessionSetup(
  sessionSetupResult:
    | EffectAcpSchema.LoadSessionResponse
    | EffectAcpSchema.NewSessionResponse
    | EffectAcpSchema.ResumeSessionResponse,
): string | undefined {
  return sessionSetupResult.models?.currentModelId?.trim() || undefined;
}
 
export function applyGrokAcpModelSelection<E>(input: {
  readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
  readonly currentModelId: string | undefined;
  readonly requestedModelId: string | undefined;
  readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
}): Effect.Effect<string | undefined, E> {
  const shouldSwitchModel =
    input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;
  if (!shouldSwitchModel) {
    return Effect.succeed(input.currentModelId);
  }
  return input.runtime
    .setSessionModel(input.requestedModelId)
    .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId));
}
Read this as: Grok’s wrapper launches `grok agent stdio`, selects its auth method, applies a t3code OAuth referrer, and switches model only when the requested native id differs.

Grok’s user-input extension is x.ai/ask_user_question (and an underscore-prefixed variant). It has a distinct cancellation response shape. Its standard ACP permission path is similar to Cursor’s but chooses a compatible permission option rather than assuming identical labels. Both adapters map tool, item, content, and plan updates into product runtime events, but neither claims that every native field becomes durable product data.

Where shared names fork

Interactive semantic fork

One shared ACP rail, four distinct product decisions

Select a concern to compare concrete adapter behavior and the boundary it does not cross.

Selected Model & mode

Model & mode

Cursor
Sets a base model, then resolves a mode through returned configuration/mode aliases.
Grok
Uses the setup result’s current model and calls session/set_model only if the requested model differs.
Boundary
Both call into ACP, but their selection policies are adapter code—not a generic negotiated product feature.
All comparisons
  1. Model & mode

    Cursor: Sets a base model, then resolves a mode through returned configuration/mode aliases.

    Grok: Uses the setup result’s current model and calls session/set_model only if the requested model differs.

    Boundary: Both call into ACP, but their selection policies are adapter code—not a generic negotiated product feature.

  2. Question

    Cursor: cursor/ask_question parks a Deferred and maps answers to canonical user-input events.

    Grok: x.ai/ask_user_question (including an underscore variant) has its own response and cancellation mapping.

    Boundary: Deferred callback waits are in-process. Durable requests exist only after later runtime ingestion.

  3. Interrupt race

    Cursor: Cancels ACP and resolves pending waits; prompt-in-flight counting prevents a superseded prompt from settling too early.

    Grok: Marks target id before a thread lock and suppresses late notifications for interrupted turns.

    Boundary: A cancel notification crosses a process boundary; neither path is a durable stop proof.

  4. Rollback

    Cursor: Truncates the adapter’s local read snapshot; no Cursor native revert call appears in this method.

    Grok: Returns an explicit unsupported provider request error.

    Boundary: Neither row is an orchestration-event rollback or a transaction with provider history.

Concrete Cursor and Grok adapter behavior, not generic ACP capability claims
ConcernCursor adapterGrok adapter
launchcursor-agent … acp; optional endpointgrok agent stdio; XAI auth/referrer setup
resumepasses a versioned Cursor session id to the shared ACP runtimepasses a versioned Grok session id to the same load-or-new path
mode/modelmodel plus config-option mode heuristic from discovered mode statecompares setup model then calls ACP session/set_model when needed
plan/input extensionsCursor plan/todo/question extensions receive bespoke mappingsXAI ask-user extension receives bespoke mapping; ordinary ACP updates can still carry plans
running second promptreuses active turn id; only the last prompt completion settles itsame intent, with turn-target-aware settlement and interrupted-turn suppression
interruptcancels ACP and resolves pending approval/input waitsmarks a target as interrupted before the lock, filters late notifications, then cancels and settles slots
rollbackemulates local rollback by truncating its in-memory turn snapshotexplicitly unsupported: returns a provider request error; no provider-side rollback is invoked

The rollback row is intentionally asymmetric. Cursor’s implementation removes entries from its local ctx.turns array; source inspection does not find a Cursor ACP native revert call in that method. That changes the adapter’s read snapshot, not necessarily the provider’s remote history. Grok refuses the operation explicitly. Orchestration revert still has its own durable events and projection cleanup (Chapter 13); neither adapter row should be read as a transaction across that durable model and native provider state.

Cursor also serializes per-thread start/stop work and tracks prompts in flight, so a second prompt becomes a steer instead of a second product turn. Its completion logic lets only the final outstanding prompt settle the turn. The shared semantic—one product turn while upstream work is running—does not make the races identical.

Evidence boundaries and useful tests

The pinned tests cover model/mode setup, mid-turn steering, cancellation, callback responses, and a regression where notification consumers accidentally died when the startSession caller fiber ended. They do not prove that an arbitrary real Cursor or Grok server will expose the same optional ACP capabilities, extension payloads, or remote-history semantics. Those are server/version-dependent integrations.

T3
Source-locked editionRead against fa219001d · 23 Aug 2026
Book search

Find a concept, module, or source path

Type two or more characters.