diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..7c747c49758 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,6 +19,9 @@ const emitInterleavedAssistantToolCalls = const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; +const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; +const xAiExitPlanFile = process.env.T3_ACP_XAI_EXIT_PLAN_FILE; +const xAiExitPlanRepeat = process.env.T3_ACP_XAI_EXIT_PLAN_REPEAT === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; @@ -773,6 +776,44 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitXAiExitPlanMode) { + if (xAiExitPlanFile) { + // Mimic Grok reporting the plan-file path on plan-mode entry, then + // racing its own plan write: exit_plan_mode carries no planContent. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "enter-plan-mode-tool-call-1", + status: "completed", + rawOutput: { + type: "EnterPlanMode", + Entered: { + message: "You have entered plan mode.", + plan_file_path: xAiExitPlanFile, + }, + }, + }, + }); + } + // Optionally re-present within the same prompt, mimicking the model + // revising the plan right after receiving the revise feedback. + const presentations = xAiExitPlanRepeat ? 2 : 1; + for (let index = 0; index < presentations; index += 1) { + const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { + sessionId: requestedSessionId, + toolCallId: `exit-plan-mode-tool-call-${index + 1}`, + ...(xAiExitPlanFile + ? { planContent: null } + : { planContent: `# Plan v${index + 1}\n\n- Add the endpoint\n- Add the test` }), + }); + if (typeof result !== "object" || result === null || !("outcome" in result)) { + throw new Error("Expected _x.ai/exit_plan_mode response outcome."); + } + } + return { stopReason: "end_turn" }; + } + if (emitXAiAskUserQuestion) { const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { method: "x.ai/ask_user_question", diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..2e0a39a65e6 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -26,7 +26,11 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; +import { + grokPromptSettlementBelongsToContext, + makeGrokAdapter, + shouldAutoApproveGrokPlan, +} from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -122,6 +126,49 @@ it("requires a settlement to match the live Grok turn", () => { ); }); +it("approves a captured Grok plan only after a fresh non-plan user prompt", () => { + // No capture yet: never approve. + assert.isFalse( + shouldAutoApproveGrokPlan({ + planCapture: undefined, + activeInteractionMode: "default", + promptSerial: 3, + }), + ); + // Same prompt re-presentation (no new user input): capture again. + assert.isFalse( + shouldAutoApproveGrokPlan({ + planCapture: { promptSerial: 3 }, + activeInteractionMode: "default", + promptSerial: 3, + }), + ); + // Plan-mode refinement prompt: capture the revised plan, do not approve. + assert.isFalse( + shouldAutoApproveGrokPlan({ + planCapture: { promptSerial: 3 }, + activeInteractionMode: "plan", + promptSerial: 4, + }), + ); + // Fresh default-mode prompt after capture — a new turn or an implement + // click steered into the still-running capturing turn: approve. + assert.isTrue( + shouldAutoApproveGrokPlan({ + planCapture: { promptSerial: 3 }, + activeInteractionMode: "default", + promptSerial: 4, + }), + ); + assert.isTrue( + shouldAutoApproveGrokPlan({ + planCapture: { promptSerial: 3 }, + activeInteractionMode: undefined, + promptSerial: 4, + }), + ); +}); + it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { @@ -1159,6 +1206,209 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("captures Grok plan approvals and approves them on implementation turns", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-exit-plan-mode"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-plan-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposedPlans: Array< + Extract + > = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "turn.proposed.completed") { + proposedPlans.push(event); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + // First presentation (agent-initiated planning in a default-mode + // thread): the plan is captured and Grok is told to stop and wait. + yield* adapter.sendTurn({ + threadId, + input: "make a plan", + attachments: [], + interactionMode: "default", + }); + assert.equal(proposedPlans.length, 1); + assert.equal( + proposedPlans[0]?.payload.planMarkdown, + "# Plan v1\n\n- Add the endpoint\n- Add the test", + ); + assert.equal(proposedPlans[0]?.raw?.method, "_x.ai/exit_plan_mode"); + + // Refinement turn (thread in plan mode): the revised plan is captured + // again instead of being approved. + yield* adapter.sendTurn({ + threadId, + input: "add a rollout step", + attachments: [], + interactionMode: "plan", + }); + assert.equal(proposedPlans.length, 2); + + // Implementation turn: the pending plan approval is granted so Grok can + // exit plan mode and build. + yield* adapter.sendTurn({ + threadId, + input: "implement the plan", + attachments: [], + interactionMode: "default", + }); + assert.equal(proposedPlans.length, 2); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const planResponses = requests.flatMap((entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "string" + ? [entry.result.outcome] + : [], + ); + assert.deepEqual(planResponses, ["rejected", "rejected", "approved"]); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("captures a plan re-presented within the capturing turn instead of approving it", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-exit-plan-same-turn"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-plan-repeat-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1", + T3_ACP_XAI_EXIT_PLAN_REPEAT: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposedPlans: Array< + Extract + > = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if ( + String(event.threadId) === String(threadId) && + event.type === "turn.proposed.completed" + ) { + proposedPlans.push(event); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + // Both presentations happen inside this single default-mode turn, so + // neither may be auto-approved on the user's behalf. + yield* adapter.sendTurn({ + threadId, + input: "make a plan", + attachments: [], + interactionMode: "default", + }); + + assert.equal(proposedPlans.length, 2); + assert.equal( + proposedPlans[1]?.payload.planMarkdown, + "# Plan v2\n\n- Add the endpoint\n- Add the test", + ); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const planResponses = requests.flatMap((entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "string" + ? [entry.result.outcome] + : [], + ); + assert.deepEqual(planResponses, ["rejected", "rejected"]); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("recovers the Grok plan from the plan file when planContent is empty", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-exit-plan-empty-content"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-plan-file-")), + ); + const planFilePath = NodePath.join(tempDir, "plan.md"); + yield* Effect.promise(() => + NodeFSP.writeFile(planFilePath, "# Recovered plan\n\n- Read from disk\n", "utf8"), + ); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1", + T3_ACP_XAI_EXIT_PLAN_FILE: planFilePath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposedPlans: Array< + Extract + > = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if ( + String(event.threadId) === String(threadId) && + event.type === "turn.proposed.completed" + ) { + proposedPlans.push(event); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "make a plan", + attachments: [], + interactionMode: "default", + }); + + assert.equal(proposedPlans.length, 1); + assert.equal(proposedPlans[0]?.payload.planMarkdown, "# Recovered plan\n\n- Read from disk"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + it.effect("continues streaming events when native notification logging fails", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-native-log-failure"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index d8c288a8292..f1e6c0e1717 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -3,6 +3,7 @@ import { type GrokSettings, EventId, type ProviderApprovalDecision, + type ProviderInteractionMode, type ProviderRuntimeEvent, type ProviderSession, type ProviderUserInputAnswers, @@ -61,10 +62,14 @@ import { } from "../acp/GrokAcpSupport.ts"; import { extractXAiAskUserQuestions, + extractXAiExitPlanModePlan, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeApprovedResponse, + makeXAiExitPlanModeCapturedResponse, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -109,6 +114,25 @@ interface GrokSessionContext { readonly pendingUserInputs: Map; turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; + /** Interaction mode of the most recent sendTurn; decides whether a Grok + * plan-approval request is captured as a proposed plan or auto-approved. */ + activeInteractionMode: ProviderInteractionMode | undefined; + /** Set once a Grok plan has been captured as a proposed plan and is + * awaiting the user's decision on the plan card. Records the prompt serial + * at capture time so a re-presentation without any new user prompt is + * captured again instead of auto-approved; only a request preceded by a + * fresh non-plan user prompt (a new turn or a steer) approves. */ + planCapture: { readonly promptSerial: number } | undefined; + /** Monotonic count of user prompts sent to this session, including steers. */ + promptSerial: number; + /** Grok's current ACP session mode (from current_mode_update), e.g. "plan". */ + currentAcpModeId: string | undefined; + /** Plan file path reported by enter_plan_mode; used to recover the plan + * when exit_plan_mode arrives with empty planContent (write/read race). */ + planFilePath: string | undefined; + /** Markdown of the most recently captured plan; recovery reads that treat + * matching plan-file contents as possibly stale (rewrite in flight). */ + lastCapturedPlanMarkdown: string | undefined; activeTurnId: TurnId | undefined; /** Turns already interrupted; late prompt RPCs must not resurrect them. */ interruptedTurnIds: Set; @@ -160,6 +184,60 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Decides whether an incoming exit_plan_mode request is the user-driven + * implementation of an already-captured plan (approve) or another plan + * presentation to capture. Approval requires a plan captured earlier AND at + * least one new non-plan user prompt since the capture — a new turn or a + * steer into the still-running capturing turn both qualify, while Grok + * re-presenting on its own within the same prompt does not. + */ +export function shouldAutoApproveGrokPlan(input: { + readonly planCapture: { readonly promptSerial: number } | undefined; + readonly activeInteractionMode: ProviderInteractionMode | undefined; + readonly promptSerial: number; +}): boolean { + return ( + input.planCapture !== undefined && + input.activeInteractionMode !== "plan" && + input.promptSerial > input.planCapture.promptSerial + ); +} + +/** + * Pulls the plan-file path out of an enter_plan_mode tool-call update. Grok + * may batch the plan-file write and exit_plan_mode in one model response, in + * which case the CLI's plan-file read races its own write and the + * exit_plan_mode request arrives with an empty planContent — the adapter then + * reads the plan file directly using this path. + */ +export function extractGrokPlanFilePath(rawPayload: unknown): string | undefined { + if (!isRecord(rawPayload)) return undefined; + const params = isRecord(rawPayload.params) ? rawPayload.params : rawPayload; + const update = params.update; + if (!isRecord(update)) return undefined; + const rawOutput = update.rawOutput; + if (!isRecord(rawOutput) || rawOutput.type !== "EnterPlanMode") return undefined; + for (const value of Object.values(rawOutput)) { + if (!isRecord(value) || typeof value.plan_file_path !== "string") { + continue; + } + const planFilePath = value.plan_file_path.trim(); + // The path is agent-supplied: accept only absolute, NUL-free paths so a + // malformed value cannot resolve somewhere surprising. Roots are limited + // to a Windows drive letter or a POSIX "/" — a bare leading backslash is + // relative on POSIX and rejected. + if ( + planFilePath.length > 0 && + !planFilePath.includes("\0") && + /^(?:[A-Za-z]:[\\/]|\/)/.test(planFilePath) + ) { + return planFilePath; + } + } + return undefined; +} + const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; @@ -663,6 +741,114 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + yield* Effect.forEach( + ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, + (method) => + acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const ctx = sessions.get(input.threadId); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + // Snapshot before the (possibly slow) capture work: a + // steer that lands while this request is being handled + // must count as arriving *after* the capture, so it can + // still approve the plan. + const requestPromptSerial = ctx?.promptSerial ?? 0; + // A plan was captured earlier and the user has since + // sent a non-plan prompt (a new turn, or an implement + // click steered into the still-running capturing turn): + // let Grok exit plan mode and build. Without a fresh + // user prompt — e.g. Grok re-presenting a revised plan + // on its own right after the revise feedback — the plan + // is captured again, never approved on the user's + // behalf. + if ( + ctx !== undefined && + shouldAutoApproveGrokPlan({ + planCapture: ctx.planCapture, + activeInteractionMode: ctx.activeInteractionMode, + promptSerial: ctx.promptSerial, + }) + ) { + ctx.planCapture = undefined; + return makeXAiExitPlanModeApprovedResponse(); + } + let planMarkdown = extractXAiExitPlanModePlan(params); + // Grok can batch the plan-file write and exit_plan_mode + // in one model response; its own plan read then races + // the write and planContent arrives empty. Recover by + // reading the plan file the CLI reported on entry. + if (planMarkdown === undefined && ctx !== undefined) { + const previousPlanMarkdown = ctx.lastCapturedPlanMarkdown; + let unchangedContents: string | undefined; + for ( + let attempt = 0; + attempt < 20 && planMarkdown === undefined; + attempt += 1 + ) { + // Re-read per attempt: the enter_plan_mode update that + // carries the path is processed on a concurrent fiber. + const planFilePath = ctx.planFilePath; + if (planFilePath === undefined) { + // Nothing to read; give the concurrent fiber a + // short grace period, then answer immediately. + if (attempt >= 5) { + break; + } + } else { + const contents = yield* fileSystem + .readFileString(planFilePath) + .pipe(Effect.orElseSucceed(() => "")); + const trimmedContents = contents.trim(); + if ( + trimmedContents.length > 0 && + trimmedContents !== previousPlanMarkdown + ) { + planMarkdown = trimmedContents; + break; + } + if (trimmedContents.length > 0) { + // The file still holds the previously captured + // plan; Grok may be rewriting it, so keep + // polling but fall back to this if nothing new + // shows up. + unchangedContents = trimmedContents; + } + } + yield* Effect.sleep("100 millis"); + } + planMarkdown ??= unchangedContents; + } + if (ctx) { + ctx.planCapture = { promptSerial: requestPromptSerial }; + ctx.lastCapturedPlanMarkdown = planMarkdown; + } + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + planMarkdown: + planMarkdown ?? "# Plan\n\n(Grok did not supply plan text.)", + }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + // Answer immediately: Grok treats this as "revise", ends + // the turn, and keeps plan mode active until the user + // acts on the plan card. + return makeXAiExitPlanModeCapturedResponse(); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -774,6 +960,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte pendingUserInputs, turns: [], lastPlanFingerprint: undefined, + activeInteractionMode: undefined, + planCapture: undefined, + promptSerial: 0, + currentAcpModeId: undefined, + planFilePath: undefined, + lastCapturedPlanMarkdown: undefined, activeTurnId: undefined, interruptedTurnIds: new Set(), promptsInFlight: 0, @@ -797,6 +989,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } if (event._tag === "ModeChanged") { + ctx.currentAcpModeId = event.modeId; return; } @@ -844,7 +1037,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "session/update", ); return; - case "ToolCallUpdated": + case "ToolCallUpdated": { + const planFilePath = extractGrokPlanFilePath(event.rawPayload); + if (planFilePath) { + ctx.planFilePath = planFilePath; + } yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp, @@ -856,6 +1053,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); return; + } case "ContentDelta": yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ @@ -927,6 +1125,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; + ctx.activeInteractionMode = input.interactionMode; ctx.session = { ...ctx.session, status: steeringTurnId === undefined ? "connecting" : "running", @@ -984,7 +1183,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } satisfies EffectAcpSchema.ContentBlock; }), ); + // Grok has no client-togglable plan mode over ACP; plan mode is + // entered by the agent's own enter_plan_mode tool. When the + // thread is in plan mode but the Grok session is not, steer the + // agent into it with a leading instruction. + const planModeNudge = + input.interactionMode === "plan" && ctx.currentAcpModeId !== "plan" + ? "[T3 Code] Plan mode is enabled for this message. Call the enter_plan_mode tool before doing anything else, and do not modify any files other than the plan file." + : undefined; const promptParts: Array = [ + ...(planModeNudge ? [{ type: "text" as const, text: planModeNudge }] : []), ...(text ? [{ type: "text" as const, text }] : []), ...imagePromptParts, ]; @@ -1038,6 +1246,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); } + // Count the prompt only once preparation has succeeded and the + // RPC is about to dispatch: a prompt that failed or was + // interrupted before reaching Grok must not count as the fresh + // user prompt that unlocks plan auto-approval. + ctx.promptSerial += 1; + return { acp: ctx.acp, acpSessionId: ctx.acpSessionId, diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76..805b9f943b8 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -10,10 +10,14 @@ import { describe, expect } from "vite-plus/test"; import { extractXAiAskUserQuestions, + extractXAiExitPlanModePlan, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeApprovedResponse, + makeXAiExitPlanModeCapturedResponse, makeXAiPromptCompletionRuntime, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -36,8 +40,55 @@ const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) => }); const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); +const decodeXAiExitPlanModeRequest = Schema.decodeUnknownSync(XAiExitPlanModeRequest); describe("XAiAcpExtension", () => { + it("extracts the plan from the real xAI exit_plan_mode payload shape", () => { + const decoded = decodeXAiExitPlanModeRequest({ + sessionId: "session-1", + toolCallId: "tool-call-1", + planContent: "# Plan\n\n- Step one\n", + }); + + expect(extractXAiExitPlanModePlan(decoded)).toEqual("# Plan\n\n- Step one"); + }); + + it("extracts the plan from wrapped _x.ai exit_plan_mode payloads", () => { + const decoded = decodeXAiExitPlanModeRequest({ + method: "_x.ai/exit_plan_mode", + params: { + sessionId: "session-1", + toolCallId: "tool-call-1", + planContent: "# Wrapped plan", + }, + }); + + expect(extractXAiExitPlanModePlan(decoded)).toEqual("# Wrapped plan"); + }); + + it("treats missing or blank exit_plan_mode plan content as absent", () => { + expect( + extractXAiExitPlanModePlan(decodeXAiExitPlanModeRequest({ sessionId: "session-1" })), + ).toBeUndefined(); + expect( + extractXAiExitPlanModePlan( + decodeXAiExitPlanModeRequest({ sessionId: "session-1", planContent: " " }), + ), + ).toBeUndefined(); + expect( + extractXAiExitPlanModePlan( + decodeXAiExitPlanModeRequest({ sessionId: "session-1", planContent: null }), + ), + ).toBeUndefined(); + }); + + it("builds exit_plan_mode responses Grok understands", () => { + expect(makeXAiExitPlanModeApprovedResponse()).toEqual({ outcome: "approved" }); + + const captured = makeXAiExitPlanModeCapturedResponse(); + expect(captured.outcome).toEqual("rejected"); + expect(captured.feedback).toContain("End your turn"); + }); it("extracts questions from the real xAI ask_user_question payload shape", () => { const questions = extractXAiAskUserQuestions({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc89..021fb81154d 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -196,6 +196,70 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan return { outcome: "cancelled" }; } +const XAiExitPlanModeParams = Schema.Struct({ + sessionId: Schema.String, + toolCallId: Schema.optional(Schema.String), + planContent: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const XAiWrappedExitPlanModeParams = Schema.Struct({ + method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), + params: XAiExitPlanModeParams, +}); + +/** + * Grok's plan-approval reverse request. When the agent finishes planning it + * calls its `exit_plan_mode` tool, which the CLI intercepts and forwards to + * the ACP client as `_x.ai/exit_plan_mode` with the plan-file contents. If the + * client cannot answer, the tool fails with "client disconnected mid-approval" + * and plan mode stays active. + */ +export const XAiExitPlanModeRequest = Schema.Union([ + XAiExitPlanModeParams, + XAiWrappedExitPlanModeParams, +]); + +type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; +export type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; + +function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams { + return "params" in params ? params.params : params; +} + +export function extractXAiExitPlanModePlan(params: XAiExitPlanModeRequest): string | undefined { + return trimmed(unwrapExitPlanModeParams(params).planContent ?? undefined); +} + +/** + * Grok interprets the response outcome as: `approved` exits plan mode and + * tells the agent to implement the plan; any other outcome is treated as + * "revise" — the tool completes with "The user wants to revise the plan." + * plus the feedback text, and plan mode stays active. (Grok's protocol also + * accepts `abandoned` to quit the plan entirely; T3 Code never sends it, so + * this type only models the outcomes T3 Code produces.) + */ +export interface XAiExitPlanModeResponse { + readonly outcome: "approved" | "rejected"; + readonly feedback?: string; +} + +export function makeXAiExitPlanModeApprovedResponse(): XAiExitPlanModeResponse { + return { outcome: "approved" }; +} + +/** + * Response sent after T3 Code captures the plan as a proposed plan. The + * feedback is surfaced to the agent as a user message, so it is phrased to + * end the turn until the user acts on the plan card. + */ +export function makeXAiExitPlanModeCapturedResponse(): XAiExitPlanModeResponse { + return { + outcome: "rejected", + feedback: + "The plan is now displayed for review. Do not present it again and do not ask follow-up questions. End your turn; I will reply with feedback or ask you to implement the plan.", + }; +} + /** * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. * The underlying runtime remains unaware of xAI methods and metadata.