authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 18:48:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:48:19-07:00
loga434677eca737771dd64703545c13eb11ba92ce2
treec34564639421b9bb7a21cfd9ef713a6d9f342048
parent083d4de5b0fd33efa28363546b6ed89e191c5200
signature Signed by PGP key B5690EEEBB952194

fix(grok): health check, model selection, and stop all work against the real CLI (#9154)

Co-authored-by: mavenskylab <mavenskylab@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

14 files changed, 936 insertions(+), 152 deletions(-)

apps/server/scripts/acp-mock-agent.ts+18-6
......@@ -293,13 +293,22 @@ function modeState(): AcpSchema.SessionModeState {
293293 };
294294}
295295
296// Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own
297// "grok-build" product name, and it rejects unknown ids in session/set_model.
296298const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
297299 {
298 modelId: "grok-build",
299 name: "Grok Build",
300 ...(initialGrokReasoningEffort
301 ? { _meta: { reasoningEffort: initialGrokReasoningEffort } }
302 : {}),
300 modelId: "grok-4.6",
301 name: "Grok 4.6",
302 _meta: {
303 totalContextTokens: 500_000,
304 supportsReasoningEffort: true,
305 reasoningEffort: initialGrokReasoningEffort ?? "high",
306 reasoningEfforts: [
307 { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: false },
308 { id: "high", value: "high", label: "High Effort", default: true },
309 { id: "low", value: "low", label: "Low Effort", default: false },
310 ],
311 },
303312 },
304313 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
305314];
......@@ -307,7 +316,7 @@ const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
307316function modelState(): AcpSchema.SessionModelState {
308317 const modelId = grokAcpModels.some((model) => model.modelId === currentModelId)
309318 ? currentModelId
310 : "grok-build";
319 : "grok-4.6";
311320 return {
312321 currentModelId: modelId,
313322 availableModels: grokAcpModels,
......@@ -324,6 +333,9 @@ const program = Effect.gen(function* () {
324333 return {
325334 protocolVersion: 1,
326335 agentCapabilities: { loadSession: true },
336 // Grok advertises model state before any session exists; the provider
337 // health check reads it from here without authenticating.
338 _meta: { modelState: modelState() },
327339 };
328340 }),
329341 );
apps/server/src/provider/Layers/GrokAdapter.test.ts+313-2
......@@ -720,13 +720,13 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
720720 0,
721721 );
722722
723 yield* Fiber.interrupt(steerSendTurnFiber);
724723 yield* adapter.interruptTurn(threadId);
725724 const completed = yield* Deferred.await(turnCompleted).pipe(
726725 Effect.timeout("2 seconds"),
727726 TestClock.withLive,
728727 );
729728 yield* Fiber.join(firstSendTurnFiber);
729 yield* Fiber.interrupt(steerSendTurnFiber);
730730 assert.equal(completed.payload.state, "cancelled");
731731
732732 yield* Fiber.interrupt(runtimeEventsFiber);
......@@ -1122,6 +1122,316 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
11221122 }).pipe(TestClock.withLive),
11231123 );
11241124
1125 it.effect("cancels an in-flight prompt when a mid-turn sendTurn steers", () =>
1126 Effect.gen(function* () {
1127 const threadId = ThreadId.make("grok-steer-cancels-in-flight");
1128 const tempDir = yield* Effect.promise(() =>
1129 NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-steer-")),
1130 );
1131 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
1132 const wrapperPath = yield* Effect.promise(() =>
1133 makeMockGrokWrapper({
1134 T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1",
1135 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
1136 }),
1137 );
1138 const adapter = yield* makeTestAdapter(wrapperPath);
1139
1140 const runtimeEvents: ProviderRuntimeEvent[] = [];
1141 const firstTurnStarted = yield* Deferred.make<TurnId>();
1142 const turnCompleted = yield* Deferred.make<void>();
1143 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1144 Effect.gen(function* () {
1145 runtimeEvents.push(event);
1146 if (String(event.threadId) !== String(threadId)) {
1147 return;
1148 }
1149 if (event.type === "turn.started" && event.turnId !== undefined) {
1150 yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore);
1151 return;
1152 }
1153 if (event.type === "turn.completed") {
1154 yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore);
1155 }
1156 }),
1157 ).pipe(Effect.forkChild);
1158
1159 yield* adapter.startSession({
1160 threadId,
1161 provider: ProviderDriverKind.make("grok"),
1162 cwd: process.cwd(),
1163 runtimeMode: "full-access",
1164 });
1165
1166 const firstSendTurnFiber = yield* adapter
1167 .sendTurn({ threadId, input: "hang until steered", attachments: [] })
1168 .pipe(Effect.forkChild);
1169 const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds"));
1170 yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"');
1171
1172 const steered = yield* adapter
1173 .sendTurn({ threadId, input: "take this instead", attachments: [] })
1174 .pipe(Effect.timeout("3 seconds"));
1175 yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds"));
1176 yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds"));
1177
1178 const requestLog = yield* Effect.promise(() => readJsonLines(requestLogPath));
1179 const methods = requestLog.flatMap((entry) =>
1180 typeof entry.method === "string" ? [entry.method] : [],
1181 );
1182 const turnStartedEvents = runtimeEvents.filter(
1183 (event) => event.type === "turn.started" && String(event.threadId) === String(threadId),
1184 );
1185 const turnCompletedEvents = runtimeEvents.filter(
1186 (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> =>
1187 event.type === "turn.completed" && String(event.threadId) === String(threadId),
1188 );
1189 const readySessions = yield* adapter.listSessions();
1190 const readySession = readySessions.find((session) => session.threadId === threadId);
1191
1192 assert.equal(String(steered.turnId), String(firstTurnId));
1193 assert.isTrue(methods.includes("session/cancel"));
1194 assert.isAtLeast(methods.filter((method) => method === "session/prompt").length, 2);
1195 assert.lengthOf(turnStartedEvents, 1);
1196 assert.lengthOf(turnCompletedEvents, 1);
1197 assert.equal(turnCompletedEvents[0]?.payload.state, "completed");
1198 assert.equal(readySession?.status, "ready");
1199 assert.isUndefined(readySession?.activeTurnId);
1200
1201 yield* Fiber.interrupt(runtimeEventsFiber);
1202 yield* adapter.stopSession(threadId);
1203 }).pipe(TestClock.withLive),
1204 );
1205
1206 it.effect(
1207 "steers a prompt that has not started ACP yet instead of letting it start after cancel",
1208 () =>
1209 Effect.gen(function* () {
1210 const threadId = ThreadId.make("grok-steer-during-prep");
1211 const tempDir = yield* Effect.promise(() =>
1212 NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-steer-prep-")),
1213 );
1214 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
1215 const wrapperPath = yield* Effect.promise(() =>
1216 makeMockGrokWrapper({
1217 T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1",
1218 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
1219 }),
1220 );
1221 const adapter = yield* makeTestAdapter(wrapperPath);
1222
1223 const runtimeEvents: ProviderRuntimeEvent[] = [];
1224 const firstTurnStarted = yield* Deferred.make<TurnId>();
1225 const turnCompleted = yield* Deferred.make<void>();
1226 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1227 Effect.gen(function* () {
1228 runtimeEvents.push(event);
1229 if (String(event.threadId) !== String(threadId)) {
1230 return;
1231 }
1232 if (event.type === "turn.started" && event.turnId !== undefined) {
1233 yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore);
1234 return;
1235 }
1236 if (event.type === "turn.completed") {
1237 yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore);
1238 }
1239 }),
1240 ).pipe(Effect.forkChild);
1241
1242 yield* adapter.startSession({
1243 threadId,
1244 provider: ProviderDriverKind.make("grok"),
1245 cwd: process.cwd(),
1246 runtimeMode: "full-access",
1247 });
1248
1249 const firstSendTurnFiber = yield* adapter
1250 .sendTurn({ threadId, input: "still preparing", attachments: [] })
1251 .pipe(Effect.forkChild);
1252 const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(
1253 Effect.timeout("2 seconds"),
1254 );
1255
1256 const steered = yield* adapter
1257 .sendTurn({ threadId, input: "steer before first prompt starts", attachments: [] })
1258 .pipe(Effect.timeout("3 seconds"));
1259 yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds"));
1260 yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds"));
1261
1262 const turnCompletedEvents = runtimeEvents.filter(
1263 (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> =>
1264 event.type === "turn.completed" && String(event.threadId) === String(threadId),
1265 );
1266 const readySessions = yield* adapter.listSessions();
1267 const readySession = readySessions.find((session) => session.threadId === threadId);
1268
1269 assert.equal(String(steered.turnId), String(firstTurnId));
1270 assert.lengthOf(turnCompletedEvents, 1);
1271 assert.equal(turnCompletedEvents[0]?.payload.state, "completed");
1272 assert.equal(readySession?.status, "ready");
1273 assert.isUndefined(readySession?.activeTurnId);
1274
1275 yield* Fiber.interrupt(runtimeEventsFiber);
1276 yield* adapter.stopSession(threadId);
1277 }).pipe(TestClock.withLive),
1278 );
1279
1280 it.effect("keeps the original prompt running when a steer fails during preparation", () =>
1281 Effect.gen(function* () {
1282 const threadId = ThreadId.make("grok-failed-steer-keeps-original-prompt");
1283 const tempDir = yield* Effect.promise(() =>
1284 NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-failed-steer-")),
1285 );
1286 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
1287 const wrapperPath = yield* Effect.promise(() =>
1288 makeMockGrokWrapper({
1289 T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1",
1290 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
1291 }),
1292 );
1293 const adapter = yield* makeTestAdapter(wrapperPath);
1294
1295 const runtimeEvents: ProviderRuntimeEvent[] = [];
1296 const firstTurnStarted = yield* Deferred.make<TurnId>();
1297 const turnCompleted = yield* Deferred.make<void>();
1298 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1299 Effect.gen(function* () {
1300 runtimeEvents.push(event);
1301 if (String(event.threadId) !== String(threadId)) {
1302 return;
1303 }
1304 if (event.type === "turn.started" && event.turnId !== undefined) {
1305 yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore);
1306 return;
1307 }
1308 if (event.type === "turn.completed") {
1309 yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore);
1310 }
1311 }),
1312 ).pipe(Effect.forkChild);
1313
1314 yield* adapter.startSession({
1315 threadId,
1316 provider: ProviderDriverKind.make("grok"),
1317 cwd: process.cwd(),
1318 runtimeMode: "full-access",
1319 });
1320
1321 const firstSendTurnFiber = yield* adapter
1322 .sendTurn({ threadId, input: "hang until a failed steer", attachments: [] })
1323 .pipe(Effect.forkChild);
1324 const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds"));
1325
1326 const steerError = yield* Effect.flip(
1327 adapter.sendTurn({
1328 threadId,
1329 input: " ",
1330 attachments: [],
1331 }),
1332 );
1333 yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"');
1334 for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
1335 yield* Effect.yieldNow;
1336 }
1337
1338 const sessionsAfterFailedSteer = yield* adapter.listSessions();
1339 const sessionAfterFailedSteer = sessionsAfterFailedSteer.find(
1340 (session) => session.threadId === threadId,
1341 );
1342 const completedBeforeInterrupt = runtimeEvents.filter(
1343 (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> =>
1344 event.type === "turn.completed" && String(event.threadId) === String(threadId),
1345 );
1346
1347 yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds"));
1348 yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds"));
1349 yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds"));
1350
1351 const turnCompletedEvents = runtimeEvents.filter(
1352 (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> =>
1353 event.type === "turn.completed" && String(event.threadId) === String(threadId),
1354 );
1355 const readySessions = yield* adapter.listSessions();
1356 const readySession = readySessions.find((session) => session.threadId === threadId);
1357
1358 assert.equal(steerError._tag, "ProviderAdapterValidationError");
1359 assert.equal(sessionAfterFailedSteer?.status, "running");
1360 assert.equal(String(sessionAfterFailedSteer?.activeTurnId), String(firstTurnId));
1361 assert.lengthOf(completedBeforeInterrupt, 0);
1362 assert.lengthOf(turnCompletedEvents, 1);
1363 assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurnId));
1364 assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled");
1365 assert.equal(readySession?.status, "ready");
1366 assert.isUndefined(readySession?.activeTurnId);
1367
1368 yield* Fiber.interrupt(runtimeEventsFiber);
1369 yield* adapter.stopSession(threadId);
1370 }).pipe(TestClock.withLive),
1371 );
1372
1373 it.effect("does not double-complete when interrupt wins before a prompt starts ACP", () =>
1374 Effect.gen(function* () {
1375 const threadId = ThreadId.make("grok-interrupt-before-prompt-start");
1376 const wrapperPath = yield* Effect.promise(() =>
1377 makeMockGrokWrapper({
1378 T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1",
1379 }),
1380 );
1381 const adapter = yield* makeTestAdapter(wrapperPath);
1382
1383 const runtimeEvents: ProviderRuntimeEvent[] = [];
1384 const firstTurnStarted = yield* Deferred.make<TurnId>();
1385 const turnCompleted = yield* Deferred.make<void>();
1386 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1387 Effect.gen(function* () {
1388 runtimeEvents.push(event);
1389 if (String(event.threadId) !== String(threadId)) {
1390 return;
1391 }
1392 if (event.type === "turn.started" && event.turnId !== undefined) {
1393 yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore);
1394 return;
1395 }
1396 if (event.type === "turn.completed") {
1397 yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore);
1398 }
1399 }),
1400 ).pipe(Effect.forkChild);
1401
1402 yield* adapter.startSession({
1403 threadId,
1404 provider: ProviderDriverKind.make("grok"),
1405 cwd: process.cwd(),
1406 runtimeMode: "full-access",
1407 });
1408
1409 const firstSendTurnFiber = yield* adapter
1410 .sendTurn({ threadId, input: "interrupt before prompt starts", attachments: [] })
1411 .pipe(Effect.forkChild);
1412 const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds"));
1413 yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds"));
1414 yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds"));
1415 yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds"));
1416
1417 const turnCompletedEvents = runtimeEvents.filter(
1418 (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> =>
1419 event.type === "turn.completed" && String(event.threadId) === String(threadId),
1420 );
1421 const readySessions = yield* adapter.listSessions();
1422 const readySession = readySessions.find((session) => session.threadId === threadId);
1423
1424 assert.lengthOf(turnCompletedEvents, 1);
1425 assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurnId));
1426 assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled");
1427 assert.equal(readySession?.status, "ready");
1428 assert.isUndefined(readySession?.activeTurnId);
1429
1430 yield* Fiber.interrupt(runtimeEventsFiber);
1431 yield* adapter.stopSession(threadId);
1432 }).pipe(TestClock.withLive),
1433 );
1434
11251435 it.effect("drops late ACP notifications after a turn is cancelled", () =>
11261436 Effect.gen(function* () {
11271437 const threadId = ThreadId.make("grok-drop-late-cancelled-notifications");
......@@ -1429,7 +1739,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
14291739 assert.equal(error._tag, "ProviderAdapterRequestError");
14301740 assert.include(error.message, "Grok usage limit reached. Try again later.");
14311741 assert.equal(readySession?.status, "ready");
1432 assert.equal(readySession?.model, "grok-build");
1742 // "grok-build" resolves to the session's current model instead of going over the wire.
1743 assert.equal(readySession?.model, "grok-4.6");
14331744 assert.isUndefined(readySession?.activeTurnId);
14341745 assert.lengthOf(terminalEvents, 1);
14351746 const [terminalEvent] = terminalEvents;
apps/server/src/provider/Layers/GrokAdapter.ts+111-27
......@@ -151,8 +151,15 @@ interface GrokSessionContext {
151151 interruptedTurnIds: Set<TurnId>;
152152 /** Number of sendTurn prompts currently in flight or being prepared.
153153 * >0 means a turn is actively running, so a new sendTurn is a steer that
154 * continues it, and only the last remaining prompt settles the turn. */
154 * cancels the in-flight prompt and continues the same turn. Only the last
155 * remaining prompt settles the turn. */
155156 promptsInFlight: number;
157 /** Monotonic id assigned to each sendTurn. Steers discard older epochs. */
158 promptEpoch: number;
159 /** Prompt epochs below this value must not start an ACP session/prompt. */
160 discardBeforeEpoch: number;
161 /** Serializes cancel-then-prompt so a steer cannot miss or hit the wrong RPC. */
162 readonly promptLifecycle: Semaphore.Semaphore;
156163 readonly livenessSignals: Queue.Queue<GrokTurnLivenessSignal>;
157164 livenessTurnId: TurnId | undefined;
158165 lastTurnActivityAtNanos: bigint | undefined;
......@@ -1279,6 +1286,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
12791286 activeTurnId: undefined,
12801287 interruptedTurnIds: new Set(),
12811288 promptsInFlight: 0,
1289 promptEpoch: 0,
1290 discardBeforeEpoch: 0,
1291 promptLifecycle: yield* Semaphore.make(1),
12821292 livenessSignals: yield* Queue.sliding<GrokTurnLivenessSignal>(1),
12831293 livenessTurnId: undefined,
12841294 lastTurnActivityAtNanos: undefined,
......@@ -1466,15 +1476,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
14661476 input.threadId,
14671477 Effect.gen(function* () {
14681478 const ctx = yield* requireSession(input.threadId);
1469 // A sendTurn while a prompt is in flight is a steer: the agent
1470 // folds the new prompt into the ongoing work, so the active turn
1471 // id is reused instead of opening a new turn.
1479 // A sendTurn while a prompt is in flight is a steer: reuse the
1480 // active turn and cancel the in-flight ACP prompt so Grok takes
1481 // the new instruction immediately, matching Claude/Codex, instead
1482 // of waiting behind serialized session/prompt.
14721483 const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined;
14731484 const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4);
14741485 // Count this prompt immediately so a superseded in-flight prompt
14751486 // resolving from here on does not settle the turn; decremented on
14761487 // preparation failure here, and after the prompt below otherwise.
14771488 ctx.promptsInFlight += 1;
1489 ctx.promptEpoch += 1;
1490 const promptEpoch = ctx.promptEpoch;
14781491 // Bind the turn id before cooperative yields so interruptTurn can
14791492 // settle this prompt even if stop arrives during preparation.
14801493 ctx.activeTurnId = turnId;
......@@ -1610,6 +1623,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
16101623 turnId,
16111624 payload: displayModel ? { model: displayModel } : {},
16121625 });
1626 } else {
1627 // Discard the previous epoch only after this replacement is
1628 // ready. A failed steer must not skip the live prompt, which
1629 // settles without a terminal event when emitTurnCompletion is
1630 // false.
1631 yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
1632 yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs);
1633 ctx.discardBeforeEpoch = promptEpoch;
16131634 }
16141635
16151636 return {
......@@ -1618,6 +1639,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
16181639 displayModel,
16191640 promptParts,
16201641 turnId,
1642 promptEpoch,
1643 promptLifecycle: ctx.promptLifecycle,
1644 steeringTurnId,
16211645 };
16221646 }).pipe(
16231647 Effect.tapCause(() =>
......@@ -1644,31 +1668,91 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
16441668 const promptFailureMessageRef = yield* Ref.make<string | undefined>(undefined);
16451669
16461670 return yield* Effect.gen(function* () {
1647 const result = yield* prepared.acp
1648 .prompt({
1649 prompt: prepared.promptParts,
1650 })
1651 .pipe(
1652 Effect.tap((promptResult) =>
1653 Effect.all(
1654 [
1655 Ref.set(promptRpcSucceeded, true),
1656 Ref.set(promptResultRef, promptResult),
1657 markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId),
1658 ],
1659 { discard: true },
1660 ),
1661 ),
1662 Effect.tapError((error) =>
1663 Ref.set(
1664 promptFailureMessageRef,
1665 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message,
1666 ).pipe(Effect.andThen(prepared.acp.drainEvents)),
1667 ),
1668 Effect.mapError((error) =>
1669 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
1671 const promptStart = yield* prepared.promptLifecycle.withPermit(
1672 Effect.gen(function* () {
1673 const liveCtx = sessions.get(input.threadId);
1674 const interrupted = liveCtx?.interruptedTurnIds.has(prepared.turnId) === true;
1675 if (
1676 !liveCtx ||
1677 liveCtx.acpSessionId !== prepared.acpSessionId ||
1678 prepared.promptEpoch < liveCtx.discardBeforeEpoch ||
1679 interrupted
1680 ) {
1681 return { _tag: "Skipped" as const, interrupted };
1682 }
1683 if (prepared.steeringTurnId !== undefined) {
1684 yield* Effect.ignore(
1685 liveCtx.acp.cancel.pipe(
1686 Effect.mapError((error) =>
1687 mapAcpToAdapterError(PROVIDER, input.threadId, "session/cancel", error),
1688 ),
1689 ),
1690 );
1691 }
1692 if (liveCtx.interruptedTurnIds.has(prepared.turnId)) {
1693 return { _tag: "Skipped" as const, interrupted: true };
1694 }
1695 const dispatched = yield* Deferred.make<void>();
1696 const fiber = yield* liveCtx.acp
1697 .prompt({ prompt: prepared.promptParts }, { dispatched })
1698 .pipe(Effect.forkChild({ startImmediately: true }));
1699 // Hold the lifecycle permit until the runtime has registered this
1700 // prompt's RPC fiber, so a later steer's session/cancel targets
1701 // this prompt. Fall through if the prompt fails before that point.
1702 yield* Effect.raceFirst(
1703 Deferred.await(dispatched),
1704 Fiber.await(fiber).pipe(Effect.asVoid),
1705 );
1706 return { _tag: "Started" as const, fiber };
1707 }),
1708 );
1709 if (promptStart._tag === "Skipped") {
1710 // Settle after releasing promptLifecycle. Holding both locks
1711 // deadlocks the next sendTurn, which takes the thread lock first.
1712 yield* withThreadLock(
1713 input.threadId,
1714 settlePromptInFlight(
1715 input.threadId,
1716 prepared.turnId,
1717 prepared.acpSessionId,
1718 promptStart.interrupted
1719 ? {
1720 completedStopReason: "cancelled",
1721 settleAllPrompts: true,
1722 }
1723 : { emitTurnCompletion: false },
16701724 ),
16711725 );
1726 yield* Ref.set(promptSettled, true);
1727 const liveCtx = sessions.get(input.threadId);
1728 return {
1729 threadId: input.threadId,
1730 turnId: prepared.turnId,
1731 resumeCursor: liveCtx?.session.resumeCursor,
1732 };
1733 }
1734
1735 const result = yield* Fiber.join(promptStart.fiber).pipe(
1736 Effect.tap((promptResult) =>
1737 Effect.all(
1738 [
1739 Ref.set(promptRpcSucceeded, true),
1740 Ref.set(promptResultRef, promptResult),
1741 markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId),
1742 ],
1743 { discard: true },
1744 ),
1745 ),
1746 Effect.tapError((error) =>
1747 Ref.set(
1748 promptFailureMessageRef,
1749 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message,
1750 ).pipe(Effect.andThen(prepared.acp.drainEvents)),
1751 ),
1752 Effect.mapError((error) =>
1753 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
1754 ),
1755 );
16721756
16731757 return yield* withThreadLock(
16741758 input.threadId,
apps/server/src/provider/Layers/GrokProvider.test.ts+187-11
......@@ -1,3 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file.
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4
15import * as NodeServices from "@effect/platform-node/NodeServices";
26import { describe, expect, it } from "@effect/vitest";
37import * as Effect from "effect/Effect";
......@@ -8,11 +12,74 @@ import { GrokSettings } from "@t3tools/contracts";
812
913import {
1014 buildGrokModelCapabilities,
15 buildGrokModelsFromSessionModelState,
1116 buildInitialGrokProviderSnapshot,
1217 checkGrokProviderStatus,
18 parseGrokModelsCliOutput,
1319} from "./GrokProvider.ts";
1420
1521const decodeGrokSettings = Schema.decodeSync(GrokSettings);
22const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
23
24const LOGGED_IN_MODELS_OUTPUT = [
25 "You are logged in with grok.com.",
26 "",
27 "Default model: grok-4.6",
28 "",
29 "Available models:",
30 " * grok-4.6 (default)",
31 " - grok-4.5",
32 "",
33].join("\n");
34
35const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace(
36 "You are logged in with grok.com.",
37 "You are not authenticated.",
38);
39
40describe("parseGrokModelsCliOutput", () => {
41 it("reads login state and model slugs, marking the default", () => {
42 const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT);
43 expect(parsed.authenticated).toBe(true);
44 expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
45 ["grok-4.6", true],
46 ["grok-4.5", false],
47 ]);
48 });
49
50 it("detects a logged-out CLI even though it exits 0", () => {
51 expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false);
52 });
53
54 it("returns unknown auth for unrecognized output", () => {
55 expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull();
56 });
57});
58
59describe("buildGrokModelsFromSessionModelState", () => {
60 it("marks the agent's current model as default and keeps reasoning options", () => {
61 const models = buildGrokModelsFromSessionModelState({
62 currentModelId: "grok-4.6",
63 availableModels: [
64 {
65 modelId: "grok-4.6",
66 name: "Grok 4.6",
67 _meta: {
68 supportsReasoningEffort: true,
69 reasoningEffort: "high",
70 reasoningEfforts: [{ value: "high", label: "High", default: true }],
71 },
72 },
73 { modelId: "grok-4.5", name: "Grok 4.5" },
74 ],
75 });
76 expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
77 ["grok-4.6", true],
78 ["grok-4.5", false],
79 ]);
80 expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1);
81 });
82});
1683
1784describe("buildGrokModelCapabilities", () => {
1885 it("preserves ACP-provided reasoning labels and the active default", () => {
......@@ -264,30 +331,139 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
264331 }),
265332 );
266333
267 it.effect("reports an error when ACP model discovery is unavailable", () =>
334 // Single-quotes a path for /bin/sh. Temp dirs and execPath never contain quotes.
335 const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
336
337 // A shell stand-in for the Grok CLI: `--version` and `models` print canned text,
338 // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata.
339 const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) =>
340 Effect.gen(function* () {
341 const fs = yield* FileSystem.FileSystem;
342 const path = yield* Path.Path;
343 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" });
344 const modelsPath = path.join(dir, "models.txt");
345 yield* fs.writeFileString(modelsPath, input.modelsOutput);
346 const grokPath = path.join(dir, "grok");
347 const mockAgentPath = path.resolve(__dirname, "../../../scripts/acp-mock-agent.ts");
348 yield* fs.writeFileString(
349 grokPath,
350 [
351 "#!/bin/sh",
352 'case "$1" in',
353 ' --version) printf "grok 1.0.13\\n"; exit 0;;',
354 ` models) cat ${shellQuote(modelsPath)}; exit 0;;`,
355 input.acp
356 ? ` agent) exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)};;`
357 : " agent) exit 3;;",
358 "esac",
359 "exit 1",
360 "",
361 ].join("\n"),
362 );
363 yield* fs.chmod(grokPath, 0o755);
364 return grokPath;
365 });
366
367 it.effect("reports ready with ACP-discovered models when logged in", () =>
268368 Effect.gen(function* () {
269369 const snapshot = yield* Effect.scoped(
270370 Effect.gen(function* () {
271 const fs = yield* FileSystem.FileSystem;
272 const path = yield* Path.Path;
273 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" });
274 const grokPath = path.join(dir, "grok");
275 yield* fs.writeFileString(
276 grokPath,
277 ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"),
371 const grokPath = yield* writeFakeGrokCli({
372 modelsOutput: LOGGED_IN_MODELS_OUTPUT,
373 acp: true,
374 });
375 return yield* checkGrokProviderStatus(
376 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
377 { ...process.env, XAI_API_KEY: "" },
278378 );
279 yield* fs.chmod(grokPath, 0o755);
379 }),
380 );
280381
382 expect(snapshot.status).toBe("ready");
383 expect(snapshot.version).toBe("1.0.13");
384 expect(snapshot.auth).toEqual({
385 status: "authenticated",
386 type: "cached_token",
387 label: "Grok account",
388 });
389 // The mock agent advertises grok-4.6 with reasoning options in initialize._meta.
390 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]);
391 expect(snapshot.models[0]?.isDefault).toBe(true);
392 expect(
393 snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [],
394 ).toEqual(["reasoningEffort"]);
395 }),
396 );
397
398 it.effect("reports unauthenticated from `grok models` without starting a session", () =>
399 Effect.gen(function* () {
400 const snapshot = yield* Effect.scoped(
401 Effect.gen(function* () {
402 const grokPath = yield* writeFakeGrokCli({
403 modelsOutput: LOGGED_OUT_MODELS_OUTPUT,
404 acp: true,
405 });
281406 return yield* checkGrokProviderStatus(
282407 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
408 { ...process.env, XAI_API_KEY: "" },
283409 );
284410 }),
285411 );
286412
287413 expect(snapshot.status).toBe("error");
414 expect(snapshot.auth.status).toBe("unauthenticated");
415 expect(snapshot.message).toContain("grok login");
416 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]);
417 }),
418 );
419
420 it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () =>
421 Effect.gen(function* () {
422 const snapshot = yield* Effect.scoped(
423 Effect.gen(function* () {
424 const grokPath = yield* writeFakeGrokCli({
425 modelsOutput: LOGGED_IN_MODELS_OUTPUT,
426 acp: false,
427 });
428 return yield* checkGrokProviderStatus(
429 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
430 { ...process.env, XAI_API_KEY: "" },
431 );
432 }),
433 );
434
435 expect(snapshot.status).toBe("warning");
288436 expect(snapshot.installed).toBe(true);
289 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]);
290 expect(snapshot.message).toContain("ACP startup failed");
437 expect(snapshot.auth.status).toBe("authenticated");
438 expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([
439 ["grok-4.6", true],
440 ["grok-4.5", false],
441 ]);
442 expect(snapshot.message).toContain("ACP initialize failed");
443 }),
444 );
445
446 it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () =>
447 Effect.gen(function* () {
448 const snapshot = yield* Effect.scoped(
449 Effect.gen(function* () {
450 const grokPath = yield* writeFakeGrokCli({
451 modelsOutput: LOGGED_OUT_MODELS_OUTPUT,
452 acp: false,
453 });
454 return yield* checkGrokProviderStatus(
455 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
456 { ...process.env, XAI_API_KEY: "xai-test-key" },
457 );
458 }),
459 );
460
461 expect(snapshot.auth).toEqual({
462 status: "authenticated",
463 type: "api_key",
464 label: "xAI API key",
465 });
466 expect(snapshot.status).toBe("warning");
291467 }),
292468 );
293469});
apps/server/src/provider/Layers/GrokProvider.ts+161-68
......@@ -2,6 +2,7 @@ import {
22 type GrokSettings,
33 type ModelCapabilities,
44 type ServerProvider,
5 type ServerProviderAuth,
56 type ServerProviderModel,
67} from "@t3tools/contracts";
78import type * as EffectAcpSchema from "effect-acp/schema";
......@@ -18,6 +19,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";
1819import { resolveSpawnCommand } from "@t3tools/shared/shell";
1920
2021import {
22 AUTH_PROBE_TIMEOUT_MS,
2123 buildServerProvider,
2224 isCommandMissingCause,
2325 parseGenericCliVersion,
......@@ -30,10 +32,12 @@ import {
3032 type ProviderMaintenanceCapabilities,
3133} from "../providerMaintenance.ts";
3234import {
35 GROK_DEFAULT_MODEL_SLUG,
3336 isValidGrokReasoningEffortToken,
3437 makeGrokAcpRuntime,
3538 resolveGrokAcpBaseModelId,
3639} from "../acp/GrokAcpSupport.ts";
40import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts";
3741import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";
3842
3943const GROK_PRESENTATION = {
......@@ -46,11 +50,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
4650});
4751
4852const VERSION_PROBE_TIMEOUT_MS = 4_000;
49const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
53// `initialize` is a single local round trip, so this is generous even on slow machines.
54const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000;
55const GROK_API_KEY_ENV = "XAI_API_KEY";
5056
5157const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
5258 {
53 slug: "grok-build",
59 slug: GROK_DEFAULT_MODEL_SLUG,
5460 name: "Grok Build",
5561 isCustom: false,
5662 capabilities: EMPTY_CAPABILITIES,
......@@ -201,56 +207,94 @@ export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): Mo
201207 : EMPTY_CAPABILITIES;
202208}
203209
204function buildGrokDiscoveredModelsFromSessionModelState(
210/** Models advertised by the ACP agent, with the session's current model marked as default. */
211export function buildGrokModelsFromSessionModelState(
205212 modelState: EffectAcpSchema.SessionModelState | null | undefined,
206213): ReadonlyArray<ServerProviderModel> {
207214 if (!modelState || modelState.availableModels.length === 0) {
208215 return [];
209216 }
217 const currentModelId = modelState.currentModelId.trim();
210218 const seen = new Set<string>();
211 return modelState.availableModels
212 .map((model): ServerProviderModel | undefined => {
213 const slug = resolveGrokAcpBaseModelId(model.modelId);
214 if (!slug || seen.has(slug)) {
215 return undefined;
216 }
217 seen.add(slug);
218 return {
219 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
220 const slug = resolveGrokAcpBaseModelId(model.modelId);
221 if (!slug || seen.has(slug)) {
222 return [];
223 }
224 seen.add(slug);
225 return [
226 {
219227 slug,
220228 name: model.name.trim() || slug,
221229 isCustom: false,
230 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
222231 capabilities: buildGrokModelCapabilities(model),
223 };
224 })
225 .filter((model): model is ServerProviderModel => model !== undefined);
232 },
233 ];
234 });
226235}
227236
228const discoverGrokModelsViaAcp = (
229 grokSettings: GrokSettings,
230 environment: NodeJS.ProcessEnv = process.env,
231) =>
232 Effect.gen(function* () {
233 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
234 const acp = yield* makeGrokAcpRuntime({
235 grokSettings,
236 environment,
237 childProcessSpawner,
238 cwd: process.cwd(),
239 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
237export interface GrokModelsCliOutput {
238 /** True or false when the CLI printed a login line, null when it printed neither. */
239 readonly authenticated: boolean | null;
240 readonly models: ReadonlyArray<ServerProviderModel>;
241}
242
243/**
244 * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the
245 * text is the only signal. Current output looks like:
246 *
247 * You are logged in with grok.com.
248 * Default model: grok-4.6
249 * Available models:
250 * * grok-4.6 (default)
251 * - grok-4.5
252 */
253export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput {
254 const authenticated = /you are logged in/i.test(output)
255 ? true
256 : /not authenticated|not logged in/i.test(output)
257 ? false
258 : null;
259
260 const seen = new Set<string>();
261 const models: ServerProviderModel[] = [];
262 for (const line of output.split(/\r?\n/)) {
263 const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/);
264 if (!bullet?.[1]) {
265 continue;
266 }
267 const slug = resolveGrokAcpBaseModelId(bullet[1]);
268 if (seen.has(slug)) {
269 continue;
270 }
271 seen.add(slug);
272 models.push({
273 slug,
274 name: displayNameFromGrokModelSlug(slug),
275 isCustom: false,
276 ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}),
277 capabilities: EMPTY_CAPABILITIES,
240278 });
241 const started = yield* acp.start();
242 return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models);
243 }).pipe(Effect.scoped);
279 }
280 return { authenticated, models };
281}
282
283function displayNameFromGrokModelSlug(slug: string): string {
284 return slug
285 .split(/[-_]/g)
286 .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part))
287 .join(" ");
288}
244289
245const runGrokVersionCommand = (
290const runGrokCliCommand = (
246291 grokSettings: GrokSettings,
247 environment: NodeJS.ProcessEnv = process.env,
292 args: ReadonlyArray<string>,
293 environment: NodeJS.ProcessEnv,
248294) =>
249295 Effect.gen(function* () {
250296 const command = grokSettings.binaryPath || "grok";
251 const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], {
252 env: environment,
253 });
297 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
254298 return yield* spawnAndCollect(
255299 command,
256300 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
......@@ -260,6 +304,27 @@ const runGrokVersionCommand = (
260304 );
261305 });
262306
307/**
308 * Reads model metadata from `initialize._meta.modelState`. This never calls `authenticate`
309 * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers.
310 */
311const discoverGrokModelsViaAcpInitialize = (
312 grokSettings: GrokSettings,
313 environment: NodeJS.ProcessEnv,
314) =>
315 Effect.gen(function* () {
316 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
317 const acp = yield* makeGrokAcpRuntime({
318 grokSettings,
319 environment,
320 childProcessSpawner,
321 cwd: process.cwd(),
322 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
323 });
324 const initialized = yield* acp.initialize();
325 return buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized));
326 }).pipe(Effect.scoped);
327
263328export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
264329 grokSettings: GrokSettings,
265330 environment: NodeJS.ProcessEnv = process.env,
......@@ -288,7 +353,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
288353 });
289354 }
290355
291 const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe(
356 const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe(
292357 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
293358 Effect.result,
294359 );
......@@ -354,55 +419,76 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
354419 });
355420 }
356421
422 // `grok models` reports login state and model slugs without starting the agent.
423 const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe(
424 Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS),
425 Effect.result,
426 );
427 // Only a clean exit is parsed. Failed invocations print help or error text that
428 // must not be read as model slugs or as a login verdict.
429 const modelsOutput =
430 Result.isSuccess(modelsResult) &&
431 Option.isSome(modelsResult.success) &&
432 modelsResult.success.value.code === 0
433 ? modelsResult.success.value
434 : undefined;
435 const cliModels: GrokModelsCliOutput = modelsOutput
436 ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`)
437 : { authenticated: null, models: [] };
438 if (!modelsOutput) {
439 yield* Effect.logWarning("Grok CLI model listing failed or timed out.", {
440 errorTag: Result.isFailure(modelsResult)
441 ? modelsResult.failure._tag
442 : Option.isNone(modelsResult.success)
443 ? "Timeout"
444 : `ExitCode${modelsResult.success.value.code}`,
445 });
446 }
447
448 const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim()
449 ? { status: "authenticated", type: "api_key", label: "xAI API key" }
450 : cliModels.authenticated === true
451 ? { status: "authenticated", type: "cached_token", label: "Grok account" }
452 : cliModels.authenticated === false
453 ? { status: "unauthenticated" }
454 : { status: "unknown" };
455
357456 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd);
358457
359 const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(
360 Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
458 const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe(
459 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
361460 Effect.exit,
362461 );
363 if (Exit.isFailure(discoveryExit)) {
364 yield* Effect.logWarning("Grok ACP model discovery failed", {
365 errorTag: causeErrorTag(discoveryExit.cause),
366 });
367 return buildServerProvider({
368 presentation: GROK_PRESENTATION,
369 enabled: grokSettings.enabled,
370 checkedAt,
371 models: fallbackModels,
372 skills,
373 probe: {
374 installed: true,
375 version,
376 status: "error",
377 auth: { status: "unknown" },
378 message: "Grok CLI is installed but ACP startup failed. Check server logs for details.",
379 },
462 const acpModels = Exit.isSuccess(acpExit) ? Option.getOrElse(acpExit.value, () => []) : [];
463 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
464 if (acpFailed) {
465 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
466 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
380467 });
381468 }
382 if (Option.isNone(discoveryExit.value)) {
383 yield* Effect.logWarning(
384 `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`,
385 );
469
470 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
471 const models =
472 discoveredModels.length > 0
473 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
474 : fallbackModels;
475
476 if (auth.status === "unauthenticated") {
386477 return buildServerProvider({
387478 presentation: GROK_PRESENTATION,
388479 enabled: grokSettings.enabled,
389480 checkedAt,
390 models: fallbackModels,
481 models,
391482 skills,
392483 probe: {
393484 installed: true,
394485 version,
395486 status: "error",
396 auth: { status: "unknown" },
397 message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`,
487 auth,
488 message: "Grok CLI is installed but not logged in. Run `grok login`.",
398489 },
399490 });
400491 }
401 const discoveredModels = discoveryExit.value.value;
402 const models =
403 discoveredModels.length > 0
404 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
405 : fallbackModels;
406492
407493 return buildServerProvider({
408494 presentation: GROK_PRESENTATION,
......@@ -413,8 +499,15 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
413499 probe: {
414500 installed: true,
415501 version,
416 status: "ready",
417 auth: { status: "unknown" },
502 // A failed metadata probe degrades the model picker, it does not make chats fail.
503 status: acpFailed ? "warning" : "ready",
504 auth,
505 ...(acpFailed
506 ? {
507 message:
508 "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.",
509 }
510 : {}),
418511 },
419512 });
420513});
apps/server/src/provider/acp/AcpRuntimeModel.ts+13-2
......@@ -713,13 +713,24 @@ export const waitForSessionLoadReplayIdle = (input: {
713713 }
714714 });
715715
716/**
717 * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any
718 * session exists. Undefined when the agent does not advertise it or the shape is unknown.
719 */
720export function sessionModelStateFromInitialize(
721 initializeResult: EffectAcpSchema.InitializeResponse,
722): EffectAcpSchema.SessionModelState | undefined {
723 const meta = initializeResult._meta;
724 const modelState = isRecord(meta) ? meta.modelState : undefined;
725 return isSessionModelState(modelState) ? modelState : undefined;
726}
727
716728export function syntheticLoadSessionResponseFromInitialize(
717729 initializeResult: EffectAcpSchema.InitializeResponse,
718730): EffectAcpSchema.LoadSessionResponse {
719731 const meta = initializeResult._meta;
720 const modelState = isRecord(meta) ? meta.modelState : undefined;
721732 const modeState = isRecord(meta) ? meta.modeState : undefined;
722 const models = isSessionModelState(modelState) ? modelState : undefined;
733 const models = sessionModelStateFromInitialize(initializeResult);
723734 const modes = isSessionModeState(modeState) ? modeState : undefined;
724735
725736 return {
apps/server/src/provider/acp/AcpSessionRuntime.ts+34-16
......@@ -182,6 +182,16 @@ export class AcpSessionRuntime extends Context.Service<
182182 * @see https://agentclientprotocol.com/protocol/extensibility
183183 */
184184 readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"];
185 /**
186 * Sends only `initialize` and returns the agent's response. Health probes use this to read
187 * advertised capabilities without authenticating or opening a session, so a probe can never
188 * start an interactive login or boot MCP servers.
189 * @see https://agentclientprotocol.com/protocol/schema#initialize
190 */
191 readonly initialize: () => Effect.Effect<
192 EffectAcpSchema.InitializeResponse,
193 EffectAcpErrors.AcpError
194 >;
185195 /**
186196 * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session.
187197 * Concurrent calls share the same in-flight startup and a failed startup may be retried.
......@@ -196,11 +206,14 @@ export class AcpSessionRuntime extends Context.Service<
196206 /** Latest configuration options observed from session setup and configuration writes. */
197207 readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
198208 /**
199 * Sends a prompt turn to the active session.
209 * Sends a prompt turn to the active session. `options.dispatched` settles once the
210 * `session/prompt` RPC is registered as the active prompt, so a caller that forks this
211 * effect knows when a later `cancel` will target this prompt.
200212 * @see https://agentclientprotocol.com/protocol/schema#session/prompt
201213 */
202214 readonly prompt: (
203215 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
216 options?: { readonly dispatched?: Deferred.Deferred<void> },
204217 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
205218 /**
206219 * Sends a real ACP `session/cancel` notification for the active session.
......@@ -537,18 +550,19 @@ export const make = (
537550 ),
538551 );
539552
553 const initializePayload = {
554 protocolVersion: 1,
555 clientCapabilities: initializeClientCapabilities,
556 clientInfo: options.clientInfo,
557 } satisfies EffectAcpSchema.InitializeRequest;
558 const sendInitialize = runLoggedRequest(
559 "initialize",
560 initializePayload,
561 acp.agent.initialize(initializePayload),
562 );
563
540564 const startOnce = Effect.gen(function* () {
541 const initializePayload = {
542 protocolVersion: 1,
543 clientCapabilities: initializeClientCapabilities,
544 clientInfo: options.clientInfo,
545 } satisfies EffectAcpSchema.InitializeRequest;
546
547 const initializeResult = yield* runLoggedRequest(
548 "initialize",
549 initializePayload,
550 acp.agent.initialize(initializePayload),
551 );
565 const initializeResult = yield* sendInitialize;
552566
553567 const authenticatePayload = {
554568 methodId: options.authMethodId,
......@@ -713,6 +727,7 @@ export const make = (
713727 handleUnknownExtNotification: acp.handleUnknownExtNotification,
714728 handleExtRequest: acp.handleExtRequest,
715729 handleExtNotification: acp.handleExtNotification,
730 initialize: () => sendInitialize,
716731 start: () => start,
717732 getEvents: () => Stream.fromQueue(eventQueue),
718733 drainEvents: Effect.gen(function* () {
......@@ -725,7 +740,7 @@ export const make = (
725740 }),
726741 getModeState: Ref.get(modeStateRef),
727742 getConfigOptions: Ref.get(configOptionsRef),
728 prompt: (payload) =>
743 prompt: (payload, promptOptions?) =>
729744 promptSerializationSemaphore.withPermit(
730745 Effect.gen(function* () {
731746 const started = yield* getStartedState;
......@@ -746,6 +761,9 @@ export const make = (
746761 acp.agent.prompt(requestPayload),
747762 ).pipe(Effect.forkIn(runtimeScope));
748763 yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber));
764 if (promptOptions?.dispatched) {
765 yield* Deferred.succeed(promptOptions.dispatched, undefined);
766 }
749767 return yield* Fiber.join(promptRpcFiber).pipe(
750768 Effect.catchCause((cause) =>
751769 Cause.hasInterruptsOnly(cause)
......@@ -774,9 +792,9 @@ export const make = (
774792 if (Option.isSome(activePromptFiber)) {
775793 yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);
776794 }
777 yield* acp.agent
778 .cancel({ sessionId: started.sessionId })
779 .pipe(Effect.ignore, Effect.forkIn(runtimeScope));
795 // Await the notification write so a replacement session/prompt
796 // cannot race ahead of session/cancel on the wire.
797 yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore);
780798 }),
781799 ),
782800 ),
apps/server/src/provider/acp/GrokAcpSupport.test.ts+30
......@@ -178,6 +178,36 @@ describe("applyGrokAcpModelSelection", () => {
178178 }),
179179 );
180180
181 it.effect("keeps the session's current model when the product slug is requested", () =>
182 Effect.gen(function* () {
183 const { runtime, modelCalls } = makeRecordingRuntime();
184 const result = yield* applyGrokAcpModelSelection({
185 runtime,
186 currentModelId: "grok-4.6",
187 requestedModelId: "grok-build",
188 mapError: (cause) => cause.message,
189 });
190 expect(modelCalls).toEqual([]);
191 expect(result).toBe("grok-4.6");
192 }),
193 );
194
195 it.effect("applies reasoning to the current model when the product slug is requested", () =>
196 Effect.gen(function* () {
197 const { runtime, modelCalls } = makeRecordingRuntime();
198 const result = yield* applyGrokAcpModelSelection({
199 runtime,
200 currentModelId: "grok-4.6",
201 currentReasoningEffort: "high",
202 requestedModelId: "grok-build",
203 requestedReasoningEffort: "xhigh",
204 mapError: (cause) => cause.message,
205 });
206 expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]);
207 expect(result).toBe("grok-4.6");
208 }),
209 );
210
181211 it.effect("skips set_model when no model is requested", () =>
182212 Effect.gen(function* () {
183213 const { runtime, modelCalls } = makeRecordingRuntime();
apps/server/src/provider/acp/GrokAcpSupport.ts+13-5
......@@ -98,10 +98,16 @@ export const makeGrokAcpRuntime = (
9898 return yield* makeXAiPromptCompletionRuntime(runtime);
9999 });
100100
101/**
102 * T3's built-in Grok slug. It is the CLI's product name, not a model id the ACP accepts,
103 * so selecting it means "use whatever model the Grok session currently runs on".
104 */
105export const GROK_DEFAULT_MODEL_SLUG = "grok-build";
106
101107export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
102108 const trimmed = model?.trim();
103 const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build";
104 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build";
109 const base = trimmed && trimmed.length > 0 ? trimmed : GROK_DEFAULT_MODEL_SLUG;
110 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG;
105111}
106112
107113const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;
......@@ -155,15 +161,17 @@ export function applyGrokAcpModelSelection<E>(input: {
155161 readonly requestedReasoningEffort?: string | undefined;
156162 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
157163}): Effect.Effect<string | undefined, E> {
158 const modelChanged =
159 input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;
164 // The product slug is never sent over the wire; it keeps the session's current model.
165 const requestedModelId =
166 input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;
167 const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId;
160168 const reasoningProvided = input.requestedReasoningEffort !== undefined;
161169 const reasoningEffort = reasoningProvided
162170 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
163171 : undefined;
164172 const reasoningEffortChanged =
165173 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
166 const targetModelId = input.requestedModelId ?? input.currentModelId;
174 const targetModelId = requestedModelId ?? input.currentModelId;
167175 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
168176 return Effect.succeed(input.currentModelId);
169177 }
apps/server/src/provider/acp/XAiAcpExtension.ts+3-3
......@@ -447,11 +447,11 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion
447447 runtime
448448 .start()
449449 .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))),
450 prompt: (payload) =>
450 prompt: (payload, promptOptions?) =>
451451 Effect.gen(function* () {
452452 const sessionId = yield* Ref.get(activeSessionIdRef);
453453 if (sessionId === undefined) {
454 return yield* runtime.prompt(payload);
454 return yield* runtime.prompt(payload, promptOptions);
455455 }
456456
457457 const promptId = yield* allocatePromptFallbackId;
......@@ -470,7 +470,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion
470470 } satisfies Omit<EffectAcpSchema.PromptRequest, "sessionId">;
471471
472472 return yield* Effect.raceFirst(
473 runtime.prompt(requestPayload),
473 runtime.prompt(requestPayload, promptOptions),
474474 Deferred.await(fallback.deferred),
475475 ).pipe(
476476 Effect.tap((response) =>
docs/internals/providers.md+11
......@@ -39,6 +39,17 @@ directory to route session and turn operations for a thread, so callers name a t
3939Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No
4040orchestration, contract, or client change is required for the common case.
4141
42### Grok health check
43
44`checkGrokProviderStatus` never opens an ACP session. It runs `grok --version`, then `grok models`
45for login state and model slugs, then a single ACP `initialize` and reads models from
46`_meta.modelState`. `authenticate` and `session/new` are skipped on purpose: `authenticate` can open
47a browser login and `session/new` boots every configured MCP server, both of which made background
48probes hang or surprise the user. A failed `initialize` degrades to `warning` with the CLI's model
49list instead of persisting `error` over a working install. The built-in `grok-build` slug is the
50CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the
51session's current model" and never sends it in `session/set_model`.
52
4253## OpenCode server ownership and catalog
4354
4455Each OpenCode provider instance owns one lazy local server for catalog discovery and
packages/contracts/src/model.ts+1
......@@ -151,6 +151,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin
151151 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,
152152 [CLAUDE_DRIVER_KIND]: "claude-sonnet-5",
153153 [CURSOR_DRIVER_KIND]: "auto",
154 // Product slug, not an ACP model id. The Grok adapter treats it as "the session's current model".
154155 [GROK_DRIVER_KIND]: "grok-build",
155156 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
156157};
packages/effect-acp/src/protocol.test.ts+7-6
......@@ -229,25 +229,24 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
229229
230230 yield* transport.notify("session/cancel", { sessionId: "session-1" });
231231
232 // A notification must not carry `id` or `headers`. Grok CLI drops frames that do.
232233 assert.deepEqual(events, [
233234 {
234235 direction: "outgoing",
235236 stage: "decoded",
236237 payload: {
237 _tag: "Request",
238 id: "",
238 _tag: "Notification",
239239 tag: "session/cancel",
240240 payload: {
241241 sessionId: "session-1",
242242 },
243 headers: [],
244243 },
245244 },
246245 {
247246 direction: "outgoing",
248247 stage: "raw",
249248 payload:
250 '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"},"id":"","headers":[]}\n',
249 '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}\n',
251250 },
252251 ]);
253252 }),
......@@ -293,11 +292,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
293292 serverRequestMethods: new Set(),
294293 });
295294
295 // Notifications encode through Schema, so the cause is the schema failure rather
296 // than the raw TypeError JSON.stringify throws. The ACP error shape is what callers see.
296297 const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip);
297298 assert.instanceOf(bigintError, AcpError.AcpProtocolParseError);
298299 assert.equal(bigintError.operation, "encode-message");
299300 assert.equal(bigintError.method, "x/test");
300 assert.instanceOf(bigintError.cause, TypeError);
301 assert.isDefined(bigintError.cause);
301302 assert.equal(
302303 bigintError.message,
303304 "ACP protocol operation 'encode-message' failed for method 'x/test'.",
......@@ -309,7 +310,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
309310 assert.instanceOf(circularError, AcpError.AcpProtocolParseError);
310311 assert.equal(circularError.operation, "encode-message");
311312 assert.equal(circularError.method, "x/test");
312 assert.instanceOf(circularError.cause, TypeError);
313 assert.isDefined(circularError.cause);
313314
314315 const requestError = yield* transport.request("x/request", 1n).pipe(
315316 Effect.match({
packages/effect-acp/src/protocol.ts+34-6
......@@ -1,6 +1,7 @@
11import * as Cause from "effect/Cause";
22import * as Effect from "effect/Effect";
33import * as Deferred from "effect/Deferred";
4import * as Exit from "effect/Exit";
45import * as Queue from "effect/Queue";
56import * as Ref from "effect/Ref";
67import * as Schema from "effect/Schema";
......@@ -77,6 +78,16 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect(
7778);
7879const parserFactory = RpcSerialization.ndJsonRpc();
7980const MAX_BUFFERED_RAW_NOTIFICATIONS = 32;
81// Outbound JSON-RPC notification: no `id`, so peers never treat it as a request.
82const encodeJsonRpcNotification = Schema.encodeUnknownExit(
83 Schema.fromJsonString(
84 Schema.Struct({
85 jsonrpc: Schema.Literal("2.0"),
86 method: Schema.String,
87 params: Schema.Unknown,
88 }),
89 ),
90);
8091
8192export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* (
8293 options: AcpPatchedProtocolOptions,
......@@ -109,6 +120,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
109120 const offerOutgoing = Effect.fn("offerOutgoing")(function* (
110121 message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded,
111122 ) {
123 // RpcClient emits `@effect/rpc/Interrupt` when a pending request's fiber is interrupted.
124 // ACP has no such method; agents log it as an error and cannot act on it, so drop it.
125 if (message._tag === "Interrupt") {
126 return;
127 }
112128 yield* logProtocol({
113129 direction: "outgoing",
114130 stage: "decoded",
......@@ -521,17 +537,29 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
521537 supportsSpanPropagation: true,
522538 });
523539
540 // JSON-RPC notifications carry no `id`. The generic Request encoder emits `id: ""` plus
541 // `headers`, which real agents (Grok CLI) parse as a malformed request and silently drop.
542 // That made `session/cancel` a no-op against Grok while the lenient mock agent accepted it.
524543 const sendNotification = Effect.fn("sendNotification")(function* (
525544 method: string,
526545 payload: unknown,
527546 ) {
528 yield* offerOutgoing({
529 _tag: "Request",
530 id: "",
531 tag: method,
532 payload,
533 headers: [],
547 yield* logProtocol({
548 direction: "outgoing",
549 stage: "decoded",
550 payload: { _tag: "Notification", tag: method, payload },
534551 });
552 const exit = encodeJsonRpcNotification({ jsonrpc: "2.0", method, params: payload });
553 if (Exit.isFailure(exit)) {
554 return yield* AcpError.AcpProtocolParseError.fromEncodingError(
555 method,
556 undefined,
557 Cause.squash(exit.cause),
558 );
559 }
560 const encoded = `${exit.value}\n`;
561 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });
562 yield* Queue.offer(outgoing, encoded);
535563 });
536564
537565 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {