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 {...@@ -293,13 +293,22 @@ function modeState(): AcpSchema.SessionModeState {
293 };293 };
294}294}
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.
296const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [298const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
297 {299 {
298 modelId: "grok-build",300 modelId: "grok-4.6",
299 name: "Grok Build",301 name: "Grok 4.6",
300 ...(initialGrokReasoningEffort302 _meta: {
301 ? { _meta: { reasoningEffort: initialGrokReasoningEffort } }303 totalContextTokens: 500_000,
302 : {}),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 },
303 },312 },
304 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },313 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
305];314];
...@@ -307,7 +316,7 @@ const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [...@@ -307,7 +316,7 @@ const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
307function modelState(): AcpSchema.SessionModelState {316function modelState(): AcpSchema.SessionModelState {
308 const modelId = grokAcpModels.some((model) => model.modelId === currentModelId)317 const modelId = grokAcpModels.some((model) => model.modelId === currentModelId)
309 ? currentModelId318 ? currentModelId
310 : "grok-build";319 : "grok-4.6";
311 return {320 return {
312 currentModelId: modelId,321 currentModelId: modelId,
313 availableModels: grokAcpModels,322 availableModels: grokAcpModels,
...@@ -324,6 +333,9 @@ const program = Effect.gen(function* () {...@@ -324,6 +333,9 @@ const program = Effect.gen(function* () {
324 return {333 return {
325 protocolVersion: 1,334 protocolVersion: 1,
326 agentCapabilities: { loadSession: true },335 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() },
327 };339 };
328 }),340 }),
329 );341 );
apps/server/src/provider/Layers/GrokAdapter.test.ts+313-2
...@@ -720,13 +720,13 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {...@@ -720,13 +720,13 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
720 0,720 0,
721 );721 );
722722
723 yield* Fiber.interrupt(steerSendTurnFiber);
724 yield* adapter.interruptTurn(threadId);723 yield* adapter.interruptTurn(threadId);
725 const completed = yield* Deferred.await(turnCompleted).pipe(724 const completed = yield* Deferred.await(turnCompleted).pipe(
726 Effect.timeout("2 seconds"),725 Effect.timeout("2 seconds"),
727 TestClock.withLive,726 TestClock.withLive,
728 );727 );
729 yield* Fiber.join(firstSendTurnFiber);728 yield* Fiber.join(firstSendTurnFiber);
729 yield* Fiber.interrupt(steerSendTurnFiber);
730 assert.equal(completed.payload.state, "cancelled");730 assert.equal(completed.payload.state, "cancelled");
731731
732 yield* Fiber.interrupt(runtimeEventsFiber);732 yield* Fiber.interrupt(runtimeEventsFiber);
...@@ -1122,6 +1122,316 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {...@@ -1122,6 +1122,316 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
1122 }).pipe(TestClock.withLive),1122 }).pipe(TestClock.withLive),
1123 );1123 );
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
1125 it.effect("drops late ACP notifications after a turn is cancelled", () =>1435 it.effect("drops late ACP notifications after a turn is cancelled", () =>
1126 Effect.gen(function* () {1436 Effect.gen(function* () {
1127 const threadId = ThreadId.make("grok-drop-late-cancelled-notifications");1437 const threadId = ThreadId.make("grok-drop-late-cancelled-notifications");
...@@ -1429,7 +1739,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {...@@ -1429,7 +1739,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
1429 assert.equal(error._tag, "ProviderAdapterRequestError");1739 assert.equal(error._tag, "ProviderAdapterRequestError");
1430 assert.include(error.message, "Grok usage limit reached. Try again later.");1740 assert.include(error.message, "Grok usage limit reached. Try again later.");
1431 assert.equal(readySession?.status, "ready");1741 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");
1433 assert.isUndefined(readySession?.activeTurnId);1744 assert.isUndefined(readySession?.activeTurnId);
1434 assert.lengthOf(terminalEvents, 1);1745 assert.lengthOf(terminalEvents, 1);
1435 const [terminalEvent] = terminalEvents;1746 const [terminalEvent] = terminalEvents;
apps/server/src/provider/Layers/GrokAdapter.ts+111-27
...@@ -151,8 +151,15 @@ interface GrokSessionContext {...@@ -151,8 +151,15 @@ interface GrokSessionContext {
151 interruptedTurnIds: Set<TurnId>;151 interruptedTurnIds: Set<TurnId>;
152 /** Number of sendTurn prompts currently in flight or being prepared.152 /** Number of sendTurn prompts currently in flight or being prepared.
153 * >0 means a turn is actively running, so a new sendTurn is a steer that153 * >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. */
155 promptsInFlight: number;156 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;
156 readonly livenessSignals: Queue.Queue<GrokTurnLivenessSignal>;163 readonly livenessSignals: Queue.Queue<GrokTurnLivenessSignal>;
157 livenessTurnId: TurnId | undefined;164 livenessTurnId: TurnId | undefined;
158 lastTurnActivityAtNanos: bigint | undefined;165 lastTurnActivityAtNanos: bigint | undefined;
...@@ -1279,6 +1286,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1279,6 +1286,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1279 activeTurnId: undefined,1286 activeTurnId: undefined,
1280 interruptedTurnIds: new Set(),1287 interruptedTurnIds: new Set(),
1281 promptsInFlight: 0,1288 promptsInFlight: 0,
1289 promptEpoch: 0,
1290 discardBeforeEpoch: 0,
1291 promptLifecycle: yield* Semaphore.make(1),
1282 livenessSignals: yield* Queue.sliding<GrokTurnLivenessSignal>(1),1292 livenessSignals: yield* Queue.sliding<GrokTurnLivenessSignal>(1),
1283 livenessTurnId: undefined,1293 livenessTurnId: undefined,
1284 lastTurnActivityAtNanos: undefined,1294 lastTurnActivityAtNanos: undefined,
...@@ -1466,15 +1476,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1466,15 +1476,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1466 input.threadId,1476 input.threadId,
1467 Effect.gen(function* () {1477 Effect.gen(function* () {
1468 const ctx = yield* requireSession(input.threadId);1478 const ctx = yield* requireSession(input.threadId);
1469 // A sendTurn while a prompt is in flight is a steer: the agent1479 // A sendTurn while a prompt is in flight is a steer: reuse the
1470 // folds the new prompt into the ongoing work, so the active turn1480 // active turn and cancel the in-flight ACP prompt so Grok takes
1471 // id is reused instead of opening a new turn.1481 // the new instruction immediately, matching Claude/Codex, instead
1482 // of waiting behind serialized session/prompt.
1472 const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined;1483 const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined;
1473 const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4);1484 const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4);
1474 // Count this prompt immediately so a superseded in-flight prompt1485 // Count this prompt immediately so a superseded in-flight prompt
1475 // resolving from here on does not settle the turn; decremented on1486 // resolving from here on does not settle the turn; decremented on
1476 // preparation failure here, and after the prompt below otherwise.1487 // preparation failure here, and after the prompt below otherwise.
1477 ctx.promptsInFlight += 1;1488 ctx.promptsInFlight += 1;
1489 ctx.promptEpoch += 1;
1490 const promptEpoch = ctx.promptEpoch;
1478 // Bind the turn id before cooperative yields so interruptTurn can1491 // Bind the turn id before cooperative yields so interruptTurn can
1479 // settle this prompt even if stop arrives during preparation.1492 // settle this prompt even if stop arrives during preparation.
1480 ctx.activeTurnId = turnId;1493 ctx.activeTurnId = turnId;
...@@ -1610,6 +1623,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1610,6 +1623,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1610 turnId,1623 turnId,
1611 payload: displayModel ? { model: displayModel } : {},1624 payload: displayModel ? { model: displayModel } : {},
1612 });1625 });
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;
1613 }1634 }
16141635
1615 return {1636 return {
...@@ -1618,6 +1639,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1618,6 +1639,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1618 displayModel,1639 displayModel,
1619 promptParts,1640 promptParts,
1620 turnId,1641 turnId,
1642 promptEpoch,
1643 promptLifecycle: ctx.promptLifecycle,
1644 steeringTurnId,
1621 };1645 };
1622 }).pipe(1646 }).pipe(
1623 Effect.tapCause(() =>1647 Effect.tapCause(() =>
...@@ -1644,31 +1668,91 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1644,31 +1668,91 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1644 const promptFailureMessageRef = yield* Ref.make<string | undefined>(undefined);1668 const promptFailureMessageRef = yield* Ref.make<string | undefined>(undefined);
16451669
1646 return yield* Effect.gen(function* () {1670 return yield* Effect.gen(function* () {
1647 const result = yield* prepared.acp1671 const promptStart = yield* prepared.promptLifecycle.withPermit(
1648 .prompt({1672 Effect.gen(function* () {
1649 prompt: prepared.promptParts,1673 const liveCtx = sessions.get(input.threadId);
1650 })1674 const interrupted = liveCtx?.interruptedTurnIds.has(prepared.turnId) === true;
1651 .pipe(1675 if (
1652 Effect.tap((promptResult) =>1676 !liveCtx ||
1653 Effect.all(1677 liveCtx.acpSessionId !== prepared.acpSessionId ||
1654 [1678 prepared.promptEpoch < liveCtx.discardBeforeEpoch ||
1655 Ref.set(promptRpcSucceeded, true),1679 interrupted
1656 Ref.set(promptResultRef, promptResult),1680 ) {
1657 markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId),1681 return { _tag: "Skipped" as const, interrupted };
1658 ],1682 }
1659 { discard: true },1683 if (prepared.steeringTurnId !== undefined) {
1660 ),1684 yield* Effect.ignore(
1661 ),1685 liveCtx.acp.cancel.pipe(
1662 Effect.tapError((error) =>1686 Effect.mapError((error) =>
1663 Ref.set(1687 mapAcpToAdapterError(PROVIDER, input.threadId, "session/cancel", error),
1664 promptFailureMessageRef,1688 ),
1665 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message,1689 ),
1666 ).pipe(Effect.andThen(prepared.acp.drainEvents)),1690 );
1667 ),1691 }
1668 Effect.mapError((error) =>1692 if (liveCtx.interruptedTurnIds.has(prepared.turnId)) {
1669 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),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 },
1670 ),1724 ),
1671 );1725 );
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
1673 return yield* withThreadLock(1757 return yield* withThreadLock(
1674 input.threadId,1758 input.threadId,
apps/server/src/provider/Layers/GrokProvider.test.ts+187-11
...@@ -1,3 +1,7 @@...@@ -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
1import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
...@@ -8,11 +12,74 @@ import { GrokSettings } from "@t3tools/contracts";...@@ -8,11 +12,74 @@ import { GrokSettings } from "@t3tools/contracts";
812
9import {13import {
10 buildGrokModelCapabilities,14 buildGrokModelCapabilities,
15 buildGrokModelsFromSessionModelState,
11 buildInitialGrokProviderSnapshot,16 buildInitialGrokProviderSnapshot,
12 checkGrokProviderStatus,17 checkGrokProviderStatus,
18 parseGrokModelsCliOutput,
13} from "./GrokProvider.ts";19} from "./GrokProvider.ts";
1420
15const decodeGrokSettings = Schema.decodeSync(GrokSettings);21const 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
17describe("buildGrokModelCapabilities", () => {84describe("buildGrokModelCapabilities", () => {
18 it("preserves ACP-provided reasoning labels and the active default", () => {85 it("preserves ACP-provided reasoning labels and the active default", () => {
...@@ -264,30 +331,139 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {...@@ -264,30 +331,139 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
264 }),331 }),
265 );332 );
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", () =>
268 Effect.gen(function* () {368 Effect.gen(function* () {
269 const snapshot = yield* Effect.scoped(369 const snapshot = yield* Effect.scoped(
270 Effect.gen(function* () {370 Effect.gen(function* () {
271 const fs = yield* FileSystem.FileSystem;371 const grokPath = yield* writeFakeGrokCli({
272 const path = yield* Path.Path;372 modelsOutput: LOGGED_IN_MODELS_OUTPUT,
273 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" });373 acp: true,
274 const grokPath = path.join(dir, "grok");374 });
275 yield* fs.writeFileString(375 return yield* checkGrokProviderStatus(
276 grokPath,376 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
277 ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"),377 { ...process.env, XAI_API_KEY: "" },
278 );378 );
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 });
281 return yield* checkGrokProviderStatus(406 return yield* checkGrokProviderStatus(
282 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),407 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
408 { ...process.env, XAI_API_KEY: "" },
283 );409 );
284 }),410 }),
285 );411 );
286412
287 expect(snapshot.status).toBe("error");413 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");
288 expect(snapshot.installed).toBe(true);436 expect(snapshot.installed).toBe(true);
289 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]);437 expect(snapshot.auth.status).toBe("authenticated");
290 expect(snapshot.message).toContain("ACP startup failed");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");
291 }),467 }),
292 );468 );
293});469});
apps/server/src/provider/Layers/GrokProvider.ts+161-68
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 type GrokSettings,2 type GrokSettings,
3 type ModelCapabilities,3 type ModelCapabilities,
4 type ServerProvider,4 type ServerProvider,
5 type ServerProviderAuth,
5 type ServerProviderModel,6 type ServerProviderModel,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import type * as EffectAcpSchema from "effect-acp/schema";8import type * as EffectAcpSchema from "effect-acp/schema";
...@@ -18,6 +19,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";...@@ -18,6 +19,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";19import { resolveSpawnCommand } from "@t3tools/shared/shell";
1920
20import {21import {
22 AUTH_PROBE_TIMEOUT_MS,
21 buildServerProvider,23 buildServerProvider,
22 isCommandMissingCause,24 isCommandMissingCause,
23 parseGenericCliVersion,25 parseGenericCliVersion,
...@@ -30,10 +32,12 @@ import {...@@ -30,10 +32,12 @@ import {
30 type ProviderMaintenanceCapabilities,32 type ProviderMaintenanceCapabilities,
31} from "../providerMaintenance.ts";33} from "../providerMaintenance.ts";
32import {34import {
35 GROK_DEFAULT_MODEL_SLUG,
33 isValidGrokReasoningEffortToken,36 isValidGrokReasoningEffortToken,
34 makeGrokAcpRuntime,37 makeGrokAcpRuntime,
35 resolveGrokAcpBaseModelId,38 resolveGrokAcpBaseModelId,
36} from "../acp/GrokAcpSupport.ts";39} from "../acp/GrokAcpSupport.ts";
40import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts";
37import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";41import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";
3842
39const GROK_PRESENTATION = {43const GROK_PRESENTATION = {
...@@ -46,11 +50,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({...@@ -46,11 +50,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
46});50});
4751
48const VERSION_PROBE_TIMEOUT_MS = 4_000;52const 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
51const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [57const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
52 {58 {
53 slug: "grok-build",59 slug: GROK_DEFAULT_MODEL_SLUG,
54 name: "Grok Build",60 name: "Grok Build",
55 isCustom: false,61 isCustom: false,
56 capabilities: EMPTY_CAPABILITIES,62 capabilities: EMPTY_CAPABILITIES,
...@@ -201,56 +207,94 @@ export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): Mo...@@ -201,56 +207,94 @@ export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): Mo
201 : EMPTY_CAPABILITIES;207 : EMPTY_CAPABILITIES;
202}208}
203209
204function buildGrokDiscoveredModelsFromSessionModelState(210/** Models advertised by the ACP agent, with the session's current model marked as default. */
211export function buildGrokModelsFromSessionModelState(
205 modelState: EffectAcpSchema.SessionModelState | null | undefined,212 modelState: EffectAcpSchema.SessionModelState | null | undefined,
206): ReadonlyArray<ServerProviderModel> {213): ReadonlyArray<ServerProviderModel> {
207 if (!modelState || modelState.availableModels.length === 0) {214 if (!modelState || modelState.availableModels.length === 0) {
208 return [];215 return [];
209 }216 }
217 const currentModelId = modelState.currentModelId.trim();
210 const seen = new Set<string>();218 const seen = new Set<string>();
211 return modelState.availableModels219 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
212 .map((model): ServerProviderModel | undefined => {220 const slug = resolveGrokAcpBaseModelId(model.modelId);
213 const slug = resolveGrokAcpBaseModelId(model.modelId);221 if (!slug || seen.has(slug)) {
214 if (!slug || seen.has(slug)) {222 return [];
215 return undefined;223 }
216 }224 seen.add(slug);
217 seen.add(slug);225 return [
218 return {226 {
219 slug,227 slug,
220 name: model.name.trim() || slug,228 name: model.name.trim() || slug,
221 isCustom: false,229 isCustom: false,
230 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
222 capabilities: buildGrokModelCapabilities(model),231 capabilities: buildGrokModelCapabilities(model),
223 };232 },
224 })233 ];
225 .filter((model): model is ServerProviderModel => model !== undefined);234 });
226}235}
227236
228const discoverGrokModelsViaAcp = (237export interface GrokModelsCliOutput {
229 grokSettings: GrokSettings,238 /** True or false when the CLI printed a login line, null when it printed neither. */
230 environment: NodeJS.ProcessEnv = process.env,239 readonly authenticated: boolean | null;
231) =>240 readonly models: ReadonlyArray<ServerProviderModel>;
232 Effect.gen(function* () {241}
233 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;242
234 const acp = yield* makeGrokAcpRuntime({243/**
235 grokSettings,244 * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the
236 environment,245 * text is the only signal. Current output looks like:
237 childProcessSpawner,246 *
238 cwd: process.cwd(),247 * You are logged in with grok.com.
239 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },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,
240 });278 });
241 const started = yield* acp.start();279 }
242 return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models);280 return { authenticated, models };
243 }).pipe(Effect.scoped);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 = (
246 grokSettings: GrokSettings,291 grokSettings: GrokSettings,
247 environment: NodeJS.ProcessEnv = process.env,292 args: ReadonlyArray<string>,
293 environment: NodeJS.ProcessEnv,
248) =>294) =>
249 Effect.gen(function* () {295 Effect.gen(function* () {
250 const command = grokSettings.binaryPath || "grok";296 const command = grokSettings.binaryPath || "grok";
251 const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], {297 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
252 env: environment,
253 });
254 return yield* spawnAndCollect(298 return yield* spawnAndCollect(
255 command,299 command,
256 ChildProcess.make(spawnCommand.command, spawnCommand.args, {300 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
...@@ -260,6 +304,27 @@ const runGrokVersionCommand = (...@@ -260,6 +304,27 @@ const runGrokVersionCommand = (
260 );304 );
261 });305 });
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
263export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (328export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
264 grokSettings: GrokSettings,329 grokSettings: GrokSettings,
265 environment: NodeJS.ProcessEnv = process.env,330 environment: NodeJS.ProcessEnv = process.env,
...@@ -288,7 +353,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -288,7 +353,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
288 });353 });
289 }354 }
290355
291 const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe(356 const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe(
292 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),357 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
293 Effect.result,358 Effect.result,
294 );359 );
...@@ -354,55 +419,76 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -354,55 +419,76 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
354 });419 });
355 }420 }
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
357 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd);456 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd);
358457
359 const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(458 const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe(
360 Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),459 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
361 Effect.exit,460 Effect.exit,
362 );461 );
363 if (Exit.isFailure(discoveryExit)) {462 const acpModels = Exit.isSuccess(acpExit) ? Option.getOrElse(acpExit.value, () => []) : [];
364 yield* Effect.logWarning("Grok ACP model discovery failed", {463 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
365 errorTag: causeErrorTag(discoveryExit.cause),464 if (acpFailed) {
366 });465 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
367 return buildServerProvider({466 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
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 },
380 });467 });
381 }468 }
382 if (Option.isNone(discoveryExit.value)) {469
383 yield* Effect.logWarning(470 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
384 `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`,471 const models =
385 );472 discoveredModels.length > 0
473 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
474 : fallbackModels;
475
476 if (auth.status === "unauthenticated") {
386 return buildServerProvider({477 return buildServerProvider({
387 presentation: GROK_PRESENTATION,478 presentation: GROK_PRESENTATION,
388 enabled: grokSettings.enabled,479 enabled: grokSettings.enabled,
389 checkedAt,480 checkedAt,
390 models: fallbackModels,481 models,
391 skills,482 skills,
392 probe: {483 probe: {
393 installed: true,484 installed: true,
394 version,485 version,
395 status: "error",486 status: "error",
396 auth: { status: "unknown" },487 auth,
397 message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`,488 message: "Grok CLI is installed but not logged in. Run `grok login`.",
398 },489 },
399 });490 });
400 }491 }
401 const discoveredModels = discoveryExit.value.value;
402 const models =
403 discoveredModels.length > 0
404 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
405 : fallbackModels;
406492
407 return buildServerProvider({493 return buildServerProvider({
408 presentation: GROK_PRESENTATION,494 presentation: GROK_PRESENTATION,
...@@ -413,8 +499,15 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -413,8 +499,15 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
413 probe: {499 probe: {
414 installed: true,500 installed: true,
415 version,501 version,
416 status: "ready",502 // A failed metadata probe degrades the model picker, it does not make chats fail.
417 auth: { status: "unknown" },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 : {}),
418 },511 },
419 });512 });
420});513});
apps/server/src/provider/acp/AcpRuntimeModel.ts+13-2
...@@ -713,13 +713,24 @@ export const waitForSessionLoadReplayIdle = (input: {...@@ -713,13 +713,24 @@ export const waitForSessionLoadReplayIdle = (input: {
713 }713 }
714 });714 });
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
716export function syntheticLoadSessionResponseFromInitialize(728export function syntheticLoadSessionResponseFromInitialize(
717 initializeResult: EffectAcpSchema.InitializeResponse,729 initializeResult: EffectAcpSchema.InitializeResponse,
718): EffectAcpSchema.LoadSessionResponse {730): EffectAcpSchema.LoadSessionResponse {
719 const meta = initializeResult._meta;731 const meta = initializeResult._meta;
720 const modelState = isRecord(meta) ? meta.modelState : undefined;
721 const modeState = isRecord(meta) ? meta.modeState : undefined;732 const modeState = isRecord(meta) ? meta.modeState : undefined;
722 const models = isSessionModelState(modelState) ? modelState : undefined;733 const models = sessionModelStateFromInitialize(initializeResult);
723 const modes = isSessionModeState(modeState) ? modeState : undefined;734 const modes = isSessionModeState(modeState) ? modeState : undefined;
724735
725 return {736 return {
apps/server/src/provider/acp/AcpSessionRuntime.ts+34-16
...@@ -182,6 +182,16 @@ export class AcpSessionRuntime extends Context.Service<...@@ -182,6 +182,16 @@ export class AcpSessionRuntime extends Context.Service<
182 * @see https://agentclientprotocol.com/protocol/extensibility182 * @see https://agentclientprotocol.com/protocol/extensibility
183 */183 */
184 readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"];184 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 >;
185 /**195 /**
186 * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session.196 * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session.
187 * Concurrent calls share the same in-flight startup and a failed startup may be retried.197 * 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<...@@ -196,11 +206,14 @@ export class AcpSessionRuntime extends Context.Service<
196 /** Latest configuration options observed from session setup and configuration writes. */206 /** Latest configuration options observed from session setup and configuration writes. */
197 readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;207 readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
198 /**208 /**
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.
200 * @see https://agentclientprotocol.com/protocol/schema#session/prompt212 * @see https://agentclientprotocol.com/protocol/schema#session/prompt
201 */213 */
202 readonly prompt: (214 readonly prompt: (
203 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,215 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
216 options?: { readonly dispatched?: Deferred.Deferred<void> },
204 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;217 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
205 /**218 /**
206 * Sends a real ACP `session/cancel` notification for the active session.219 * Sends a real ACP `session/cancel` notification for the active session.
...@@ -537,18 +550,19 @@ export const make = (...@@ -537,18 +550,19 @@ export const make = (
537 ),550 ),
538 );551 );
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
540 const startOnce = Effect.gen(function* () {564 const startOnce = Effect.gen(function* () {
541 const initializePayload = {565 const initializeResult = yield* sendInitialize;
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 );
552566
553 const authenticatePayload = {567 const authenticatePayload = {
554 methodId: options.authMethodId,568 methodId: options.authMethodId,
...@@ -713,6 +727,7 @@ export const make = (...@@ -713,6 +727,7 @@ export const make = (
713 handleUnknownExtNotification: acp.handleUnknownExtNotification,727 handleUnknownExtNotification: acp.handleUnknownExtNotification,
714 handleExtRequest: acp.handleExtRequest,728 handleExtRequest: acp.handleExtRequest,
715 handleExtNotification: acp.handleExtNotification,729 handleExtNotification: acp.handleExtNotification,
730 initialize: () => sendInitialize,
716 start: () => start,731 start: () => start,
717 getEvents: () => Stream.fromQueue(eventQueue),732 getEvents: () => Stream.fromQueue(eventQueue),
718 drainEvents: Effect.gen(function* () {733 drainEvents: Effect.gen(function* () {
...@@ -725,7 +740,7 @@ export const make = (...@@ -725,7 +740,7 @@ export const make = (
725 }),740 }),
726 getModeState: Ref.get(modeStateRef),741 getModeState: Ref.get(modeStateRef),
727 getConfigOptions: Ref.get(configOptionsRef),742 getConfigOptions: Ref.get(configOptionsRef),
728 prompt: (payload) =>743 prompt: (payload, promptOptions?) =>
729 promptSerializationSemaphore.withPermit(744 promptSerializationSemaphore.withPermit(
730 Effect.gen(function* () {745 Effect.gen(function* () {
731 const started = yield* getStartedState;746 const started = yield* getStartedState;
...@@ -746,6 +761,9 @@ export const make = (...@@ -746,6 +761,9 @@ export const make = (
746 acp.agent.prompt(requestPayload),761 acp.agent.prompt(requestPayload),
747 ).pipe(Effect.forkIn(runtimeScope));762 ).pipe(Effect.forkIn(runtimeScope));
748 yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber));763 yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber));
764 if (promptOptions?.dispatched) {
765 yield* Deferred.succeed(promptOptions.dispatched, undefined);
766 }
749 return yield* Fiber.join(promptRpcFiber).pipe(767 return yield* Fiber.join(promptRpcFiber).pipe(
750 Effect.catchCause((cause) =>768 Effect.catchCause((cause) =>
751 Cause.hasInterruptsOnly(cause)769 Cause.hasInterruptsOnly(cause)
...@@ -774,9 +792,9 @@ export const make = (...@@ -774,9 +792,9 @@ export const make = (
774 if (Option.isSome(activePromptFiber)) {792 if (Option.isSome(activePromptFiber)) {
775 yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);793 yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);
776 }794 }
777 yield* acp.agent795 // Await the notification write so a replacement session/prompt
778 .cancel({ sessionId: started.sessionId })796 // cannot race ahead of session/cancel on the wire.
779 .pipe(Effect.ignore, Effect.forkIn(runtimeScope));797 yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore);
780 }),798 }),
781 ),799 ),
782 ),800 ),
apps/server/src/provider/acp/GrokAcpSupport.test.ts+30
...@@ -178,6 +178,36 @@ describe("applyGrokAcpModelSelection", () => {...@@ -178,6 +178,36 @@ describe("applyGrokAcpModelSelection", () => {
178 }),178 }),
179 );179 );
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
181 it.effect("skips set_model when no model is requested", () =>211 it.effect("skips set_model when no model is requested", () =>
182 Effect.gen(function* () {212 Effect.gen(function* () {
183 const { runtime, modelCalls } = makeRecordingRuntime();213 const { runtime, modelCalls } = makeRecordingRuntime();
apps/server/src/provider/acp/GrokAcpSupport.ts+13-5
...@@ -98,10 +98,16 @@ export const makeGrokAcpRuntime = (...@@ -98,10 +98,16 @@ export const makeGrokAcpRuntime = (
98 return yield* makeXAiPromptCompletionRuntime(runtime);98 return yield* makeXAiPromptCompletionRuntime(runtime);
99 });99 });
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
101export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {107export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
102 const trimmed = model?.trim();108 const trimmed = model?.trim();
103 const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build";109 const base = trimmed && trimmed.length > 0 ? trimmed : GROK_DEFAULT_MODEL_SLUG;
104 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build";110 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG;
105}111}
106112
107const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;113const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;
...@@ -155,15 +161,17 @@ export function applyGrokAcpModelSelection<E>(input: {...@@ -155,15 +161,17 @@ export function applyGrokAcpModelSelection<E>(input: {
155 readonly requestedReasoningEffort?: string | undefined;161 readonly requestedReasoningEffort?: string | undefined;
156 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;162 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
157}): Effect.Effect<string | undefined, E> {163}): Effect.Effect<string | undefined, E> {
158 const modelChanged =164 // The product slug is never sent over the wire; it keeps the session's current model.
159 input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;165 const requestedModelId =
166 input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;
167 const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId;
160 const reasoningProvided = input.requestedReasoningEffort !== undefined;168 const reasoningProvided = input.requestedReasoningEffort !== undefined;
161 const reasoningEffort = reasoningProvided169 const reasoningEffort = reasoningProvided
162 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)170 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
163 : undefined;171 : undefined;
164 const reasoningEffortChanged =172 const reasoningEffortChanged =
165 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;173 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
166 const targetModelId = input.requestedModelId ?? input.currentModelId;174 const targetModelId = requestedModelId ?? input.currentModelId;
167 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {175 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
168 return Effect.succeed(input.currentModelId);176 return Effect.succeed(input.currentModelId);
169 }177 }
apps/server/src/provider/acp/XAiAcpExtension.ts+3-3
...@@ -447,11 +447,11 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion...@@ -447,11 +447,11 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion
447 runtime447 runtime
448 .start()448 .start()
449 .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))),449 .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))),
450 prompt: (payload) =>450 prompt: (payload, promptOptions?) =>
451 Effect.gen(function* () {451 Effect.gen(function* () {
452 const sessionId = yield* Ref.get(activeSessionIdRef);452 const sessionId = yield* Ref.get(activeSessionIdRef);
453 if (sessionId === undefined) {453 if (sessionId === undefined) {
454 return yield* runtime.prompt(payload);454 return yield* runtime.prompt(payload, promptOptions);
455 }455 }
456456
457 const promptId = yield* allocatePromptFallbackId;457 const promptId = yield* allocatePromptFallbackId;
...@@ -470,7 +470,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion...@@ -470,7 +470,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion
470 } satisfies Omit<EffectAcpSchema.PromptRequest, "sessionId">;470 } satisfies Omit<EffectAcpSchema.PromptRequest, "sessionId">;
471471
472 return yield* Effect.raceFirst(472 return yield* Effect.raceFirst(
473 runtime.prompt(requestPayload),473 runtime.prompt(requestPayload, promptOptions),
474 Deferred.await(fallback.deferred),474 Deferred.await(fallback.deferred),
475 ).pipe(475 ).pipe(
476 Effect.tap((response) =>476 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...@@ -39,6 +39,17 @@ directory to route session and turn operations for a thread, so callers name a t
39Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No39Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No
40orchestration, contract, or client change is required for the common case.40orchestration, 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
42## OpenCode server ownership and catalog53## OpenCode server ownership and catalog
4354
44Each OpenCode provider instance owns one lazy local server for catalog discovery and55Each 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...@@ -151,6 +151,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin
151 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,151 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,
152 [CLAUDE_DRIVER_KIND]: "claude-sonnet-5",152 [CLAUDE_DRIVER_KIND]: "claude-sonnet-5",
153 [CURSOR_DRIVER_KIND]: "auto",153 [CURSOR_DRIVER_KIND]: "auto",
154 // Product slug, not an ACP model id. The Grok adapter treats it as "the session's current model".
154 [GROK_DRIVER_KIND]: "grok-build",155 [GROK_DRIVER_KIND]: "grok-build",
155 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",156 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
156};157};
packages/effect-acp/src/protocol.test.ts+7-6
...@@ -229,25 +229,24 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -229,25 +229,24 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
229229
230 yield* transport.notify("session/cancel", { sessionId: "session-1" });230 yield* transport.notify("session/cancel", { sessionId: "session-1" });
231231
232 // A notification must not carry `id` or `headers`. Grok CLI drops frames that do.
232 assert.deepEqual(events, [233 assert.deepEqual(events, [
233 {234 {
234 direction: "outgoing",235 direction: "outgoing",
235 stage: "decoded",236 stage: "decoded",
236 payload: {237 payload: {
237 _tag: "Request",238 _tag: "Notification",
238 id: "",
239 tag: "session/cancel",239 tag: "session/cancel",
240 payload: {240 payload: {
241 sessionId: "session-1",241 sessionId: "session-1",
242 },242 },
243 headers: [],
244 },243 },
245 },244 },
246 {245 {
247 direction: "outgoing",246 direction: "outgoing",
248 stage: "raw",247 stage: "raw",
249 payload:248 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',
251 },250 },
252 ]);251 ]);
253 }),252 }),
...@@ -293,11 +292,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -293,11 +292,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
293 serverRequestMethods: new Set(),292 serverRequestMethods: new Set(),
294 });293 });
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.
296 const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip);297 const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip);
297 assert.instanceOf(bigintError, AcpError.AcpProtocolParseError);298 assert.instanceOf(bigintError, AcpError.AcpProtocolParseError);
298 assert.equal(bigintError.operation, "encode-message");299 assert.equal(bigintError.operation, "encode-message");
299 assert.equal(bigintError.method, "x/test");300 assert.equal(bigintError.method, "x/test");
300 assert.instanceOf(bigintError.cause, TypeError);301 assert.isDefined(bigintError.cause);
301 assert.equal(302 assert.equal(
302 bigintError.message,303 bigintError.message,
303 "ACP protocol operation 'encode-message' failed for method 'x/test'.",304 "ACP protocol operation 'encode-message' failed for method 'x/test'.",
...@@ -309,7 +310,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -309,7 +310,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
309 assert.instanceOf(circularError, AcpError.AcpProtocolParseError);310 assert.instanceOf(circularError, AcpError.AcpProtocolParseError);
310 assert.equal(circularError.operation, "encode-message");311 assert.equal(circularError.operation, "encode-message");
311 assert.equal(circularError.method, "x/test");312 assert.equal(circularError.method, "x/test");
312 assert.instanceOf(circularError.cause, TypeError);313 assert.isDefined(circularError.cause);
313314
314 const requestError = yield* transport.request("x/request", 1n).pipe(315 const requestError = yield* transport.request("x/request", 1n).pipe(
315 Effect.match({316 Effect.match({
packages/effect-acp/src/protocol.ts+34-6
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Cause from "effect/Cause";1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Deferred from "effect/Deferred";3import * as Deferred from "effect/Deferred";
4import * as Exit from "effect/Exit";
4import * as Queue from "effect/Queue";5import * as Queue from "effect/Queue";
5import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
...@@ -77,6 +78,16 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect(...@@ -77,6 +78,16 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect(
77);78);
78const parserFactory = RpcSerialization.ndJsonRpc();79const parserFactory = RpcSerialization.ndJsonRpc();
79const MAX_BUFFERED_RAW_NOTIFICATIONS = 32;80const 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
81export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* (92export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* (
82 options: AcpPatchedProtocolOptions,93 options: AcpPatchedProtocolOptions,
...@@ -109,6 +120,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -109,6 +120,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
109 const offerOutgoing = Effect.fn("offerOutgoing")(function* (120 const offerOutgoing = Effect.fn("offerOutgoing")(function* (
110 message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded,121 message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded,
111 ) {122 ) {
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 }
112 yield* logProtocol({128 yield* logProtocol({
113 direction: "outgoing",129 direction: "outgoing",
114 stage: "decoded",130 stage: "decoded",
...@@ -521,17 +537,29 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -521,17 +537,29 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
521 supportsSpanPropagation: true,537 supportsSpanPropagation: true,
522 });538 });
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.
524 const sendNotification = Effect.fn("sendNotification")(function* (543 const sendNotification = Effect.fn("sendNotification")(function* (
525 method: string,544 method: string,
526 payload: unknown,545 payload: unknown,
527 ) {546 ) {
528 yield* offerOutgoing({547 yield* logProtocol({
529 _tag: "Request",548 direction: "outgoing",
530 id: "",549 stage: "decoded",
531 tag: method,550 payload: { _tag: "Notification", tag: method, payload },
532 payload,
533 headers: [],
534 });551 });
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);
535 });563 });
536564
537 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {565 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {