Skip to content

feat(grok): plan mode via /plan text mapping - #5424

Closed
EnzoTironi wants to merge 3 commits into
pingdotgg:mainfrom
EnzoTironi:pr/grok-plan
Closed

feat(grok): plan mode via /plan text mapping#5424
EnzoTironi wants to merge 3 commits into
pingdotgg:mainfrom
EnzoTironi:pr/grok-plan

Conversation

@EnzoTironi

@EnzoTironi EnzoTironi commented Aug 5, 2026

Copy link
Copy Markdown

What Changed

Map T3 Plan/Build interaction mode onto Grok's /plan slash command on send, and show the composer interaction-mode toggle for Grok (live ACP does not advertise session modes).

Stacked on #5423. Fixes #5419

Why

Grok Build enters plan mode via /plan text, not ACP session modes. Without this mapping, the Plan toggle is a no-op on Grok threads. Complementary to #5409 (plan surface: exit_plan_mode + proposed-plan cards); this PR is plan entry only.

UI Changes

Composer Plan/Build toggle becomes available for Grok (existing control; no new chrome). Screenshot pending when UI PR is rebased clean.

Checklist

  • This PR is small and focused — tip still carries stacked native core (will slim for review)
  • I explained what changed and why
  • UI before/after screenshots
  • Video N/A

Known review findings

  • Empty/attachment-only plan prompts currently skip /plan (should send /plan alone)

Test plan

  • applyGrokPlanModeToPromptText unit tests
  • Composer shows plan toggle for Grok in isolated web test

Model: grok-4.5 (Grok Build)


Note

Medium Risk
Large changes to Grok turn/session lifecycle (process restarts, prompt settlement, catalog pubsub); no auth or billing, but regressions could affect live Grok threads and the context meter.

Overview
Plan entry (PR focus): Grok no longer relies on ACP session modes for Plan/Build—the provider snapshot enables the composer interaction-mode toggle, and sendTurn maps interactionMode: "plan" to a /plan-prefixed prompt (without double-prefixing). Empty or attachment-only plan sends still skip /plan alone (known gap).

Broader Grok ACP changes in the diff: The adapter and provider layer are aligned with live Grok 0.2.x wire behavior: CLI spawn flags for model, --reasoning-effort, and --always-approve; process restart + session resume when reasoning effort changes mid-thread (blocked while a turn is in flight). Token usage is emitted from usage_update and prompt result _meta for the context meter; session_info_update titles become thread.metadata.updated; initialize/available_commands_update feed slash commands and skills into the provider snapshot (with live snapshot stream updates from GrokDriver). xAI exit_plan_mode is handled as plan approval with turn.proposed.completed. ACP parsing adds thought chunks, usage, commands, config, and session-info updates; the mock agent and tests cover these paths.

Reviewed by Cursor Bugbot for commit 2447738. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ae81d2b-494e-44e5-b86d-4bfee745feb7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 5, 2026
Comment on lines +298 to +309
if (isRecord(initializeModelState) && Array.isArray(initializeModelState.availableModels)) {
return pick(
initializeModelState.availableModels as ReadonlyArray<{
modelId: string;
_meta?: unknown;
}>,
input.preferredModelId ??
(typeof initializeModelState.currentModelId === "string"
? initializeModelState.currentModelId
: undefined),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/GrokAdapter.ts:298

initializeMeta.modelState.availableModels is only checked with Array.isArray, then each entry is cast to { modelId: string; _meta?: unknown } without validation. If the array contains a malformed entry such as null, pick calls model.modelId inside find and throws, failing session startup instead of ignoring unusable metadata. Validate each entry as a record with a string modelId before passing it to pick.

Suggested change
if (isRecord(initializeModelState) && Array.isArray(initializeModelState.availableModels)) {
return pick(
initializeModelState.availableModels as ReadonlyArray<{
modelId: string;
_meta?: unknown;
}>,
input.preferredModelId ??
(typeof initializeModelState.currentModelId === "string"
? initializeModelState.currentModelId
: undefined),
);
}
const initializeModelState = input.initializeMeta?.modelState;
if (isRecord(initializeModelState) && Array.isArray(initializeModelState.availableModels)) {
+ const models = initializeModelState.availableModels.filter(
+ (model): model is { modelId: string; _meta?: unknown } =>
+ isRecord(model) && typeof model.modelId === "string",
+ );
return pick(
models,
input.preferredModelId ??
(typeof initializeModelState.currentModelId === "string"
? initializeModelState.currentModelId
: undefined),
);
}
Also found in 1 other location(s)

apps/server/src/provider/Layers/GrokProvider.ts:285

initializeMeta.modelState is unchecked metadata but is cast directly to SessionModelState and passed to buildGrokDiscoveredModelsFromSessionModelState. If Grok supplies a malformed or partial _meta.modelState (for example {}), the helper evaluates modelState.availableModels.length and throws, turning an otherwise successful ACP startup into a failed provider health check instead of simply ignoring the fallback metadata.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/GrokAdapter.ts around lines 298-309:

`initializeMeta.modelState.availableModels` is only checked with `Array.isArray`, then each entry is cast to `{ modelId: string; _meta?: unknown }` without validation. If the array contains a malformed entry such as `null`, `pick` calls `model.modelId` inside `find` and throws, failing session startup instead of ignoring unusable metadata. Validate each entry as a record with a string `modelId` before passing it to `pick`.

Also found in 1 other location(s):
- apps/server/src/provider/Layers/GrokProvider.ts:285 -- `initializeMeta.modelState` is unchecked metadata but is cast directly to `SessionModelState` and passed to `buildGrokDiscoveredModelsFromSessionModelState`. If Grok supplies a malformed or partial `_meta.modelState` (for example `{}`), the helper evaluates `modelState.availableModels.length` and throws, turning an otherwise successful ACP startup into a failed provider health check instead of simply ignoring the fallback metadata.

Comment on lines +296 to +297
).flatMap((command) => {
const name = typeof command.name === "string" ? command.name.trim() : "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/GrokProvider.ts:296

When initializeMeta.availableCommands contains null or a primitive element (e.g. ["help", null, 42]), the flatMap callback accesses command.name without first checking that command is an object, which throws a TypeError. This crashes the entire Grok ACP discovery and causes the provider health check to fail instead of skipping the malformed command. Add a guard like typeof command !== "object" || command === null before accessing command.name.

Suggested change
).flatMap((command) => {
const name = typeof command.name === "string" ? command.name.trim() : "";
).flatMap((command) => {
if (typeof command !== "object" || command === null) return [];
const name = typeof command.name === "string" ? command.name.trim() : "";
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/GrokProvider.ts around lines 296-297:

When `initializeMeta.availableCommands` contains `null` or a primitive element (e.g. `["help", null, 42]`), the `flatMap` callback accesses `command.name` without first checking that `command` is an object, which throws a `TypeError`. This crashes the entire Grok ACP discovery and causes the provider health check to fail instead of skipping the malformed command. Add a guard like `typeof command !== "object" || command === null` before accessing `command.name`.

Comment on lines +286 to +290
if (!models || models.length === 0) return undefined;
const preferredMatch = preferred
? models.find((model) => model.modelId === preferred)
: undefined;
return preferredMatch?._meta ?? models[0]?._meta;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/GrokAdapter.ts:286

pick falls back to models[0]._meta when the preferred model is found but has no _meta field, so it returns a different model's metadata. This causes the session to be initialized with the wrong model's totalContextTokens and reasoningEffort, producing an incorrect context window limit and reasoning-effort state. The fallback to models[0]?._meta should only apply when no preferred model matches, not when the matched model lacks _meta.

Suggested change
if (!models || models.length === 0) return undefined;
const preferredMatch = preferred
? models.find((model) => model.modelId === preferred)
: undefined;
return preferredMatch?._meta ?? models[0]?._meta;
if (!models || models.length === 0) return undefined;
if (preferred) {
const preferredMatch = models.find((model) => model.modelId === preferred);
if (preferredMatch) return preferredMatch._meta;
}
return models[0]?._meta;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/GrokAdapter.ts around lines 286-290:

`pick` falls back to `models[0]._meta` when the preferred model is found but has no `_meta` field, so it returns a *different* model's metadata. This causes the session to be initialized with the wrong model's `totalContextTokens` and `reasoningEffort`, producing an incorrect context window limit and reasoning-effort state. The fallback to `models[0]?._meta` should only apply when no preferred model matches, not when the matched model lacks `_meta`.

env: processEnv,
});

const commandCatalogRef = yield* Ref.make({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Drivers/GrokDriver.ts:113

mergeCommandCatalog treats an empty live catalog as "not initialized" and falls back to the previous snapshot's arrays. When onAvailableCommands receives an empty available_commands_update (i.e., the provider removed all commands), the catalog ref is correctly cleared to empty arrays, but mergeCommandCatalog sees length === 0 and keeps the stale slashCommands/skills from the last health probe. Removed commands and skills therefore remain advertised to clients indefinitely instead of being cleared.

The length > 0 guard conflates "no live data yet" with "live data says empty." Track whether the live catalog has been initialized with a separate boolean flag (or Option), then use the ref arrays unconditionally once initialized.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Drivers/GrokDriver.ts around line 113:

`mergeCommandCatalog` treats an empty live catalog as "not initialized" and falls back to the previous snapshot's arrays. When `onAvailableCommands` receives an empty `available_commands_update` (i.e., the provider removed all commands), the catalog ref is correctly cleared to empty arrays, but `mergeCommandCatalog` sees `length === 0` and keeps the stale `slashCommands`/`skills` from the last health probe. Removed commands and skills therefore remain advertised to clients indefinitely instead of being cleared.

The `length > 0` guard conflates "no live data yet" with "live data says empty." Track whether the live catalog has been initialized with a separate boolean flag (or `Option`), then use the ref arrays unconditionally once initialized.

Comment on lines +96 to +106
const trimmed = input.text?.trim();
if (!trimmed) {
return trimmed;
}
if (input.interactionMode === "plan") {
if (/^\/plan(?:\s|$)/i.test(trimmed)) {
return trimmed;
}
return `/plan ${trimmed}`;
}
return trimmed;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High acp/GrokAcpSupport.ts:96

When interactionMode is "plan" and the user's text is empty (e.g. an attachment-only prompt), applyGrokPlanModeToPromptText returns an empty string instead of "/plan", so the request runs in default mode despite the caller requesting plan mode. The early return at line 97-99 skips the plan-mode branch entirely for blank text. Consider returning "/plan" when interactionMode is "plan" and the trimmed text is empty.

   const trimmed = input.text?.trim();
-  if (!trimmed) {
-    return trimmed;
-  }
-  if (input.interactionMode === "plan") {
+  if (input.interactionMode === "plan") {
+    if (!trimmed) {
+      return "/plan";
+    }
     if (/^\/plan(?:\s|$)/i.test(trimmed)) {
       return trimmed;
     }
     return `/plan ${trimmed}`;
   }
+  if (!trimmed) {
+    return trimmed;
+  }
   return trimmed;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/acp/GrokAcpSupport.ts around lines 96-106:

When `interactionMode` is `"plan"` and the user's text is empty (e.g. an attachment-only prompt), `applyGrokPlanModeToPromptText` returns an empty string instead of `"/plan"`, so the request runs in default mode despite the caller requesting plan mode. The early return at line 97-99 skips the plan-mode branch entirely for blank text. Consider returning `"/plan"` when `interactionMode` is `"plan"` and the trimmed text is empty.

yield* offerGrokPromptTokenUsage(live, prepared.turnId, promptResult._meta);
}),
).pipe(Effect.catch(() => Effect.void));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate Grok token usage events

Medium Severity

Successful session/prompt RPCs can emit duplicate thread.token-usage.updated events. Token usage is published once when the RPC returns and again in the Effect.ensuring block, leading to double-counting in the context meter and activity timeline.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0c02a19. Configure here.

threadId: ctx.threadId,
turnId: notificationTurnId,
usedTokens: event.usage.used,
...(maxTokens !== undefined ? { maxTokens } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usage updates dropped after turn

Medium Severity

New UsageUpdated handling runs only after a guard that requires ctx.activeTurnId. When the turn is settled early (for example via xAI prompt_complete) and activeTurnId is cleared before a late usage_update notification arrives, that usage is discarded even though prompt _meta may not have carried complete token fields.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0c02a19. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

5 blocking correctness issues found. This PR introduces significant new feature functionality (plan mode, reasoning effort selection, exit plan approval workflows) with ~1900 new lines across multiple files. Multiple unresolved High severity review comments identify potential crashes from malformed metadata and incorrect plan mode behavior for empty prompts.

You can customize Macroscope's approvability policy. Learn more.

@EnzoTironi

Copy link
Copy Markdown
Author

Review findings from Bugbot/Macroscope for this slice are fixed on the Zoen product tip (zoen/main @ 16474ab0d). Will restack / re-push these PR heads after further slim-slicing. Open remaining: effort restart without session.exited (or adopt #5403 set_model path).

Shared AcpRuntimeModel/CoreRuntimeEvents/SessionRuntime and xAI
extension hooks used by the Grok adapter (and related fixtures).
Grok driver/adapter/provider core: slash catalog, process-scoped
effort, usage meter, auth surface, set_model. Plan toggle and
multi-agent task mapping land in later stack layers.
@EnzoTironi

Copy link
Copy Markdown
Author

Closing: plan entry + plan surface are included in #5423 (native ACP parity) so we keep one reviewable Grok adapter PR instead of three XXL tips that each dumped the full stack. Issue #5419 remains linked from #5423.

@EnzoTironi EnzoTironi closed this Aug 5, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2447738. Configure here.

}): string | undefined {
const trimmed = input.text?.trim();
if (!trimmed) {
return trimmed;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plan mode skips empty prompts

High Severity

When the composer is in Plan mode and the prompt text is empty or whitespace, applyGrokPlanModeToPromptText returns early. This prevents the /plan prefix from being added. As GrokAdapter only includes text for truthy results, attachment-only messages sent in Plan mode reach Grok without the /plan command, causing plan entry to fail.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2447738. Configure here.

runtimeMode: restartDecision.runtimeMode,
...(restartDecision.resumeCursor ? { resumeCursor: restartDecision.resumeCursor } : {}),
...(input.modelSelection ? { modelSelection: input.modelSelection } : {}),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effort restart emits session exited

Medium Severity

Changing Grok reasoning effort mid-sendTurn calls stopSessionInternal, which publishes session.exited and marks the orchestration session stopped, then startSession publishes session.started again—all before the turn’s turn.started for that send completes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2447738. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Grok provider: plan mode via /plan text mapping

1 participant