| author | |
| committer | |
| log | a434677eca737771dd64703545c13eb11ba92ce2 |
| tree | c34564639421b9bb7a21cfd9ef713a6d9f342048 |
| parent | 083d4de5b0fd33efa28363546b6ed89e191c5200 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 293 | }; |
| 294 | 294 | } |
| 295 | 295 | |
| 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. | |
| 296 | 298 | const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ |
| 297 | 299 | { |
| 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 | }, | |
| 303 | 312 | }, |
| 304 | 313 | { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, |
| 305 | 314 | ]; |
| ... | ... | @@ -307,7 +316,7 @@ const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ |
| 307 | 316 | function modelState(): AcpSchema.SessionModelState { |
| 308 | 317 | const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) |
| 309 | 318 | ? currentModelId |
| 310 | : "grok-build"; | |
| 319 | : "grok-4.6"; | |
| 311 | 320 | return { |
| 312 | 321 | currentModelId: modelId, |
| 313 | 322 | availableModels: grokAcpModels, |
| ... | ... | @@ -324,6 +333,9 @@ const program = Effect.gen(function* () { |
| 324 | 333 | return { |
| 325 | 334 | protocolVersion: 1, |
| 326 | 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 | 720 | 0, |
| 721 | 721 | ); |
| 722 | 722 | |
| 723 | yield* Fiber.interrupt(steerSendTurnFiber); | |
| 724 | 723 | yield* adapter.interruptTurn(threadId); |
| 725 | 724 | const completed = yield* Deferred.await(turnCompleted).pipe( |
| 726 | 725 | Effect.timeout("2 seconds"), |
| 727 | 726 | TestClock.withLive, |
| 728 | 727 | ); |
| 729 | 728 | yield* Fiber.join(firstSendTurnFiber); |
| 729 | yield* Fiber.interrupt(steerSendTurnFiber); | |
| 730 | 730 | assert.equal(completed.payload.state, "cancelled"); |
| 731 | 731 | |
| 732 | 732 | yield* Fiber.interrupt(runtimeEventsFiber); |
| ... | ... | @@ -1122,6 +1122,316 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 1122 | 1122 | }).pipe(TestClock.withLive), |
| 1123 | 1123 | ); |
| 1124 | 1124 | |
| 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 | 1435 | it.effect("drops late ACP notifications after a turn is cancelled", () => |
| 1126 | 1436 | Effect.gen(function* () { |
| 1127 | 1437 | const threadId = ThreadId.make("grok-drop-late-cancelled-notifications"); |
| ... | ... | @@ -1429,7 +1739,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 1429 | 1739 | assert.equal(error._tag, "ProviderAdapterRequestError"); |
| 1430 | 1740 | assert.include(error.message, "Grok usage limit reached. Try again later."); |
| 1431 | 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 | 1744 | assert.isUndefined(readySession?.activeTurnId); |
| 1434 | 1745 | assert.lengthOf(terminalEvents, 1); |
| 1435 | 1746 | const [terminalEvent] = terminalEvents; |
apps/server/src/provider/Layers/GrokAdapter.ts+111-27| ... | ... | @@ -151,8 +151,15 @@ interface GrokSessionContext { |
| 151 | 151 | interruptedTurnIds: Set<TurnId>; |
| 152 | 152 | /** Number of sendTurn prompts currently in flight or being prepared. |
| 153 | 153 | * >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 | 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 | 163 | readonly livenessSignals: Queue.Queue<GrokTurnLivenessSignal>; |
| 157 | 164 | livenessTurnId: TurnId | undefined; |
| 158 | 165 | lastTurnActivityAtNanos: bigint | undefined; |
| ... | ... | @@ -1279,6 +1286,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1279 | 1286 | activeTurnId: undefined, |
| 1280 | 1287 | interruptedTurnIds: new Set(), |
| 1281 | 1288 | promptsInFlight: 0, |
| 1289 | promptEpoch: 0, | |
| 1290 | discardBeforeEpoch: 0, | |
| 1291 | promptLifecycle: yield* Semaphore.make(1), | |
| 1282 | 1292 | livenessSignals: yield* Queue.sliding<GrokTurnLivenessSignal>(1), |
| 1283 | 1293 | livenessTurnId: undefined, |
| 1284 | 1294 | lastTurnActivityAtNanos: undefined, |
| ... | ... | @@ -1466,15 +1476,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1466 | 1476 | input.threadId, |
| 1467 | 1477 | Effect.gen(function* () { |
| 1468 | 1478 | 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. | |
| 1472 | 1483 | const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; |
| 1473 | 1484 | const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); |
| 1474 | 1485 | // Count this prompt immediately so a superseded in-flight prompt |
| 1475 | 1486 | // resolving from here on does not settle the turn; decremented on |
| 1476 | 1487 | // preparation failure here, and after the prompt below otherwise. |
| 1477 | 1488 | ctx.promptsInFlight += 1; |
| 1489 | ctx.promptEpoch += 1; | |
| 1490 | const promptEpoch = ctx.promptEpoch; | |
| 1478 | 1491 | // Bind the turn id before cooperative yields so interruptTurn can |
| 1479 | 1492 | // settle this prompt even if stop arrives during preparation. |
| 1480 | 1493 | ctx.activeTurnId = turnId; |
| ... | ... | @@ -1610,6 +1623,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1610 | 1623 | turnId, |
| 1611 | 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 | } |
| 1614 | 1635 | |
| 1615 | 1636 | return { |
| ... | ... | @@ -1618,6 +1639,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1618 | 1639 | displayModel, |
| 1619 | 1640 | promptParts, |
| 1620 | 1641 | turnId, |
| 1642 | promptEpoch, | |
| 1643 | promptLifecycle: ctx.promptLifecycle, | |
| 1644 | steeringTurnId, | |
| 1621 | 1645 | }; |
| 1622 | 1646 | }).pipe( |
| 1623 | 1647 | Effect.tapCause(() => |
| ... | ... | @@ -1644,31 +1668,91 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1644 | 1668 | const promptFailureMessageRef = yield* Ref.make<string | undefined>(undefined); |
| 1645 | 1669 | |
| 1646 | 1670 | 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 }, | |
| 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 | ); | |
| 1672 | 1756 | |
| 1673 | 1757 | return yield* withThreadLock( |
| 1674 | 1758 | 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. | |
| 2 | import * as NodePath from "node:path"; | |
| 3 | import * as NodeURL from "node:url"; | |
| 4 | ||
| 1 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 7 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -8,11 +12,74 @@ import { GrokSettings } from "@t3tools/contracts"; |
| 8 | 12 | |
| 9 | 13 | import { |
| 10 | 14 | buildGrokModelCapabilities, |
| 15 | buildGrokModelsFromSessionModelState, | |
| 11 | 16 | buildInitialGrokProviderSnapshot, |
| 12 | 17 | checkGrokProviderStatus, |
| 18 | parseGrokModelsCliOutput, | |
| 13 | 19 | } from "./GrokProvider.ts"; |
| 14 | 20 | |
| 15 | 21 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 22 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | |
| 23 | ||
| 24 | const 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 | ||
| 35 | const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace( | |
| 36 | "You are logged in with grok.com.", | |
| 37 | "You are not authenticated.", | |
| 38 | ); | |
| 39 | ||
| 40 | describe("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 | ||
| 59 | describe("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 | }); | |
| 16 | 83 | |
| 17 | 84 | describe("buildGrokModelCapabilities", () => { |
| 18 | 85 | it("preserves ACP-provided reasoning labels and the active default", () => { |
| ... | ... | @@ -264,30 +331,139 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { |
| 264 | 331 | }), |
| 265 | 332 | ); |
| 266 | 333 | |
| 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 | 368 | Effect.gen(function* () { |
| 269 | 369 | const snapshot = yield* Effect.scoped( |
| 270 | 370 | 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: "" }, | |
| 278 | 378 | ); |
| 279 | yield* fs.chmod(grokPath, 0o755); | |
| 379 | }), | |
| 380 | ); | |
| 280 | 381 | |
| 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 | 406 | return yield* checkGrokProviderStatus( |
| 282 | 407 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), |
| 408 | { ...process.env, XAI_API_KEY: "" }, | |
| 283 | 409 | ); |
| 284 | 410 | }), |
| 285 | 411 | ); |
| 286 | 412 | |
| 287 | 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 | 436 | 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"); | |
| 291 | 467 | }), |
| 292 | 468 | ); |
| 293 | 469 | }); |
apps/server/src/provider/Layers/GrokProvider.ts+161-68| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | type GrokSettings, |
| 3 | 3 | type ModelCapabilities, |
| 4 | 4 | type ServerProvider, |
| 5 | type ServerProviderAuth, | |
| 5 | 6 | type ServerProviderModel, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| ... | ... | @@ -18,6 +19,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; |
| 18 | 19 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 19 | 20 | |
| 20 | 21 | import { |
| 22 | AUTH_PROBE_TIMEOUT_MS, | |
| 21 | 23 | buildServerProvider, |
| 22 | 24 | isCommandMissingCause, |
| 23 | 25 | parseGenericCliVersion, |
| ... | ... | @@ -30,10 +32,12 @@ import { |
| 30 | 32 | type ProviderMaintenanceCapabilities, |
| 31 | 33 | } from "../providerMaintenance.ts"; |
| 32 | 34 | import { |
| 35 | GROK_DEFAULT_MODEL_SLUG, | |
| 33 | 36 | isValidGrokReasoningEffortToken, |
| 34 | 37 | makeGrokAcpRuntime, |
| 35 | 38 | resolveGrokAcpBaseModelId, |
| 36 | 39 | } from "../acp/GrokAcpSupport.ts"; |
| 40 | import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts"; | |
| 37 | 41 | import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; |
| 38 | 42 | |
| 39 | 43 | const GROK_PRESENTATION = { |
| ... | ... | @@ -46,11 +50,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 46 | 50 | }); |
| 47 | 51 | |
| 48 | 52 | const VERSION_PROBE_TIMEOUT_MS = 4_000; |
| 49 | const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; | |
| 53 | // `initialize` is a single local round trip, so this is generous even on slow machines. | |
| 54 | const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; | |
| 55 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | |
| 50 | 56 | |
| 51 | 57 | const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 52 | 58 | { |
| 53 | slug: "grok-build", | |
| 59 | slug: GROK_DEFAULT_MODEL_SLUG, | |
| 54 | 60 | name: "Grok Build", |
| 55 | 61 | isCustom: false, |
| 56 | 62 | capabilities: EMPTY_CAPABILITIES, |
| ... | ... | @@ -201,56 +207,94 @@ export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): Mo |
| 201 | 207 | : EMPTY_CAPABILITIES; |
| 202 | 208 | } |
| 203 | 209 | |
| 204 | function buildGrokDiscoveredModelsFromSessionModelState( | |
| 210 | /** Models advertised by the ACP agent, with the session's current model marked as default. */ | |
| 211 | export function buildGrokModelsFromSessionModelState( | |
| 205 | 212 | modelState: EffectAcpSchema.SessionModelState | null | undefined, |
| 206 | 213 | ): ReadonlyArray<ServerProviderModel> { |
| 207 | 214 | if (!modelState || modelState.availableModels.length === 0) { |
| 208 | 215 | return []; |
| 209 | 216 | } |
| 217 | const currentModelId = modelState.currentModelId.trim(); | |
| 210 | 218 | 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 | { | |
| 219 | 227 | slug, |
| 220 | 228 | name: model.name.trim() || slug, |
| 221 | 229 | isCustom: false, |
| 230 | ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}), | |
| 222 | 231 | capabilities: buildGrokModelCapabilities(model), |
| 223 | }; | |
| 224 | }) | |
| 225 | .filter((model): model is ServerProviderModel => model !== undefined); | |
| 232 | }, | |
| 233 | ]; | |
| 234 | }); | |
| 226 | 235 | } |
| 227 | 236 | |
| 228 | const 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" }, | |
| 237 | export 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 | */ | |
| 253 | export 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(); | |
| 242 | return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); | |
| 243 | }).pipe(Effect.scoped); | |
| 279 | } | |
| 280 | return { authenticated, models }; | |
| 281 | } | |
| 282 | ||
| 283 | function displayNameFromGrokModelSlug(slug: string): string { | |
| 284 | return slug | |
| 285 | .split(/[-_]/g) | |
| 286 | .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) | |
| 287 | .join(" "); | |
| 288 | } | |
| 244 | 289 | |
| 245 | const runGrokVersionCommand = ( | |
| 290 | const runGrokCliCommand = ( | |
| 246 | 291 | grokSettings: GrokSettings, |
| 247 | environment: NodeJS.ProcessEnv = process.env, | |
| 292 | args: ReadonlyArray<string>, | |
| 293 | environment: NodeJS.ProcessEnv, | |
| 248 | 294 | ) => |
| 249 | 295 | Effect.gen(function* () { |
| 250 | 296 | 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 }); | |
| 254 | 298 | return yield* spawnAndCollect( |
| 255 | 299 | command, |
| 256 | 300 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| ... | ... | @@ -260,6 +304,27 @@ const runGrokVersionCommand = ( |
| 260 | 304 | ); |
| 261 | 305 | }); |
| 262 | 306 | |
| 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 | */ | |
| 311 | const 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 | ||
| 263 | 328 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( |
| 264 | 329 | grokSettings: GrokSettings, |
| 265 | 330 | environment: NodeJS.ProcessEnv = process.env, |
| ... | ... | @@ -288,7 +353,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func |
| 288 | 353 | }); |
| 289 | 354 | } |
| 290 | 355 | |
| 291 | const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe( | |
| 356 | const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe( | |
| 292 | 357 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), |
| 293 | 358 | Effect.result, |
| 294 | 359 | ); |
| ... | ... | @@ -354,55 +419,76 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func |
| 354 | 419 | }); |
| 355 | 420 | } |
| 356 | 421 | |
| 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 | 456 | const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); |
| 358 | 457 | |
| 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), | |
| 361 | 460 | Effect.exit, |
| 362 | 461 | ); |
| 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", | |
| 380 | 467 | }); |
| 381 | 468 | } |
| 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") { | |
| 386 | 477 | return buildServerProvider({ |
| 387 | 478 | presentation: GROK_PRESENTATION, |
| 388 | 479 | enabled: grokSettings.enabled, |
| 389 | 480 | checkedAt, |
| 390 | models: fallbackModels, | |
| 481 | models, | |
| 391 | 482 | skills, |
| 392 | 483 | probe: { |
| 393 | 484 | installed: true, |
| 394 | 485 | version, |
| 395 | 486 | 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`.", | |
| 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; | |
| 406 | 492 | |
| 407 | 493 | return buildServerProvider({ |
| 408 | 494 | presentation: GROK_PRESENTATION, |
| ... | ... | @@ -413,8 +499,15 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func |
| 413 | 499 | probe: { |
| 414 | 500 | installed: true, |
| 415 | 501 | 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 | : {}), | |
| 418 | 511 | }, |
| 419 | 512 | }); |
| 420 | 513 | }); |
apps/server/src/provider/acp/AcpRuntimeModel.ts+13-2| ... | ... | @@ -713,13 +713,24 @@ export const waitForSessionLoadReplayIdle = (input: { |
| 713 | 713 | } |
| 714 | 714 | }); |
| 715 | 715 | |
| 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 | */ | |
| 720 | export 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 | ||
| 716 | 728 | export function syntheticLoadSessionResponseFromInitialize( |
| 717 | 729 | initializeResult: EffectAcpSchema.InitializeResponse, |
| 718 | 730 | ): EffectAcpSchema.LoadSessionResponse { |
| 719 | 731 | const meta = initializeResult._meta; |
| 720 | const modelState = isRecord(meta) ? meta.modelState : undefined; | |
| 721 | 732 | const modeState = isRecord(meta) ? meta.modeState : undefined; |
| 722 | const models = isSessionModelState(modelState) ? modelState : undefined; | |
| 733 | const models = sessionModelStateFromInitialize(initializeResult); | |
| 723 | 734 | const modes = isSessionModeState(modeState) ? modeState : undefined; |
| 724 | 735 | |
| 725 | 736 | return { |
apps/server/src/provider/acp/AcpSessionRuntime.ts+34-16| ... | ... | @@ -182,6 +182,16 @@ export class AcpSessionRuntime extends Context.Service< |
| 182 | 182 | * @see https://agentclientprotocol.com/protocol/extensibility |
| 183 | 183 | */ |
| 184 | 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 | 196 | * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. |
| 187 | 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 | 206 | /** Latest configuration options observed from session setup and configuration writes. */ |
| 197 | 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 | 212 | * @see https://agentclientprotocol.com/protocol/schema#session/prompt |
| 201 | 213 | */ |
| 202 | 214 | readonly prompt: ( |
| 203 | 215 | payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">, |
| 216 | options?: { readonly dispatched?: Deferred.Deferred<void> }, | |
| 204 | 217 | ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; |
| 205 | 218 | /** |
| 206 | 219 | * Sends a real ACP `session/cancel` notification for the active session. |
| ... | ... | @@ -537,18 +550,19 @@ export const make = ( |
| 537 | 550 | ), |
| 538 | 551 | ); |
| 539 | 552 | |
| 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 | 564 | 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; | |
| 552 | 566 | |
| 553 | 567 | const authenticatePayload = { |
| 554 | 568 | methodId: options.authMethodId, |
| ... | ... | @@ -713,6 +727,7 @@ export const make = ( |
| 713 | 727 | handleUnknownExtNotification: acp.handleUnknownExtNotification, |
| 714 | 728 | handleExtRequest: acp.handleExtRequest, |
| 715 | 729 | handleExtNotification: acp.handleExtNotification, |
| 730 | initialize: () => sendInitialize, | |
| 716 | 731 | start: () => start, |
| 717 | 732 | getEvents: () => Stream.fromQueue(eventQueue), |
| 718 | 733 | drainEvents: Effect.gen(function* () { |
| ... | ... | @@ -725,7 +740,7 @@ export const make = ( |
| 725 | 740 | }), |
| 726 | 741 | getModeState: Ref.get(modeStateRef), |
| 727 | 742 | getConfigOptions: Ref.get(configOptionsRef), |
| 728 | prompt: (payload) => | |
| 743 | prompt: (payload, promptOptions?) => | |
| 729 | 744 | promptSerializationSemaphore.withPermit( |
| 730 | 745 | Effect.gen(function* () { |
| 731 | 746 | const started = yield* getStartedState; |
| ... | ... | @@ -746,6 +761,9 @@ export const make = ( |
| 746 | 761 | acp.agent.prompt(requestPayload), |
| 747 | 762 | ).pipe(Effect.forkIn(runtimeScope)); |
| 748 | 763 | yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); |
| 764 | if (promptOptions?.dispatched) { | |
| 765 | yield* Deferred.succeed(promptOptions.dispatched, undefined); | |
| 766 | } | |
| 749 | 767 | return yield* Fiber.join(promptRpcFiber).pipe( |
| 750 | 768 | Effect.catchCause((cause) => |
| 751 | 769 | Cause.hasInterruptsOnly(cause) |
| ... | ... | @@ -774,9 +792,9 @@ export const make = ( |
| 774 | 792 | if (Option.isSome(activePromptFiber)) { |
| 775 | 793 | yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); |
| 776 | 794 | } |
| 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); | |
| 780 | 798 | }), |
| 781 | 799 | ), |
| 782 | 800 | ), |
apps/server/src/provider/acp/GrokAcpSupport.test.ts+30| ... | ... | @@ -178,6 +178,36 @@ describe("applyGrokAcpModelSelection", () => { |
| 178 | 178 | }), |
| 179 | 179 | ); |
| 180 | 180 | |
| 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 | 211 | it.effect("skips set_model when no model is requested", () => |
| 182 | 212 | Effect.gen(function* () { |
| 183 | 213 | const { runtime, modelCalls } = makeRecordingRuntime(); |
apps/server/src/provider/acp/GrokAcpSupport.ts+13-5| ... | ... | @@ -98,10 +98,16 @@ export const makeGrokAcpRuntime = ( |
| 98 | 98 | return yield* makeXAiPromptCompletionRuntime(runtime); |
| 99 | 99 | }); |
| 100 | 100 | |
| 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 | */ | |
| 105 | export const GROK_DEFAULT_MODEL_SLUG = "grok-build"; | |
| 106 | ||
| 101 | 107 | export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { |
| 102 | 108 | 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; | |
| 105 | 111 | } |
| 106 | 112 | |
| 107 | 113 | const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; |
| ... | ... | @@ -155,15 +161,17 @@ export function applyGrokAcpModelSelection<E>(input: { |
| 155 | 161 | readonly requestedReasoningEffort?: string | undefined; |
| 156 | 162 | readonly mapError: (cause: EffectAcpErrors.AcpError) => E; |
| 157 | 163 | }): 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; | |
| 160 | 168 | const reasoningProvided = input.requestedReasoningEffort !== undefined; |
| 161 | 169 | const reasoningEffort = reasoningProvided |
| 162 | 170 | ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) |
| 163 | 171 | : undefined; |
| 164 | 172 | const reasoningEffortChanged = |
| 165 | 173 | reasoningProvided && reasoningEffort !== input.currentReasoningEffort; |
| 166 | const targetModelId = input.requestedModelId ?? input.currentModelId; | |
| 174 | const targetModelId = requestedModelId ?? input.currentModelId; | |
| 167 | 175 | if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { |
| 168 | 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 | 447 | runtime |
| 448 | 448 | .start() |
| 449 | 449 | .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))), |
| 450 | prompt: (payload) => | |
| 450 | prompt: (payload, promptOptions?) => | |
| 451 | 451 | Effect.gen(function* () { |
| 452 | 452 | const sessionId = yield* Ref.get(activeSessionIdRef); |
| 453 | 453 | if (sessionId === undefined) { |
| 454 | return yield* runtime.prompt(payload); | |
| 454 | return yield* runtime.prompt(payload, promptOptions); | |
| 455 | 455 | } |
| 456 | 456 | |
| 457 | 457 | const promptId = yield* allocatePromptFallbackId; |
| ... | ... | @@ -470,7 +470,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion |
| 470 | 470 | } satisfies Omit<EffectAcpSchema.PromptRequest, "sessionId">; |
| 471 | 471 | |
| 472 | 472 | return yield* Effect.raceFirst( |
| 473 | runtime.prompt(requestPayload), | |
| 473 | runtime.prompt(requestPayload, promptOptions), | |
| 474 | 474 | Deferred.await(fallback.deferred), |
| 475 | 475 | ).pipe( |
| 476 | 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 | 39 | Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No |
| 40 | 40 | orchestration, contract, or client change is required for the common case. |
| 41 | 41 | |
| 42 | ### Grok health check | |
| 43 | ||
| 44 | `checkGrokProviderStatus` never opens an ACP session. It runs `grok --version`, then `grok models` | |
| 45 | for 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 | |
| 47 | a browser login and `session/new` boots every configured MCP server, both of which made background | |
| 48 | probes hang or surprise the user. A failed `initialize` degrades to `warning` with the CLI's model | |
| 49 | list instead of persisting `error` over a working install. The built-in `grok-build` slug is the | |
| 50 | CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the | |
| 51 | session's current model" and never sends it in `session/set_model`. | |
| 52 | ||
| 42 | 53 | ## OpenCode server ownership and catalog |
| 43 | 54 | |
| 44 | 55 | Each 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 | 151 | [CODEX_DRIVER_KIND]: DEFAULT_MODEL, |
| 152 | 152 | [CLAUDE_DRIVER_KIND]: "claude-sonnet-5", |
| 153 | 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 | 155 | [GROK_DRIVER_KIND]: "grok-build", |
| 155 | 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 | 229 | |
| 230 | 230 | yield* transport.notify("session/cancel", { sessionId: "session-1" }); |
| 231 | 231 | |
| 232 | // A notification must not carry `id` or `headers`. Grok CLI drops frames that do. | |
| 232 | 233 | assert.deepEqual(events, [ |
| 233 | 234 | { |
| 234 | 235 | direction: "outgoing", |
| 235 | 236 | stage: "decoded", |
| 236 | 237 | payload: { |
| 237 | _tag: "Request", | |
| 238 | id: "", | |
| 238 | _tag: "Notification", | |
| 239 | 239 | tag: "session/cancel", |
| 240 | 240 | payload: { |
| 241 | 241 | sessionId: "session-1", |
| 242 | 242 | }, |
| 243 | headers: [], | |
| 244 | 243 | }, |
| 245 | 244 | }, |
| 246 | 245 | { |
| 247 | 246 | direction: "outgoing", |
| 248 | 247 | stage: "raw", |
| 249 | 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 | 292 | serverRequestMethods: new Set(), |
| 294 | 293 | }); |
| 295 | 294 | |
| 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 | 297 | const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); |
| 297 | 298 | assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); |
| 298 | 299 | assert.equal(bigintError.operation, "encode-message"); |
| 299 | 300 | assert.equal(bigintError.method, "x/test"); |
| 300 | assert.instanceOf(bigintError.cause, TypeError); | |
| 301 | assert.isDefined(bigintError.cause); | |
| 301 | 302 | assert.equal( |
| 302 | 303 | bigintError.message, |
| 303 | 304 | "ACP protocol operation 'encode-message' failed for method 'x/test'.", |
| ... | ... | @@ -309,7 +310,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { |
| 309 | 310 | assert.instanceOf(circularError, AcpError.AcpProtocolParseError); |
| 310 | 311 | assert.equal(circularError.operation, "encode-message"); |
| 311 | 312 | assert.equal(circularError.method, "x/test"); |
| 312 | assert.instanceOf(circularError.cause, TypeError); | |
| 313 | assert.isDefined(circularError.cause); | |
| 313 | 314 | |
| 314 | 315 | const requestError = yield* transport.request("x/request", 1n).pipe( |
| 315 | 316 | Effect.match({ |
packages/effect-acp/src/protocol.ts+34-6| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Cause from "effect/Cause"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Exit from "effect/Exit"; | |
| 4 | 5 | import * as Queue from "effect/Queue"; |
| 5 | 6 | import * as Ref from "effect/Ref"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -77,6 +78,16 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( |
| 77 | 78 | ); |
| 78 | 79 | const parserFactory = RpcSerialization.ndJsonRpc(); |
| 79 | 80 | const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; |
| 81 | // Outbound JSON-RPC notification: no `id`, so peers never treat it as a request. | |
| 82 | const 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 | ); | |
| 80 | 91 | |
| 81 | 92 | export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( |
| 82 | 93 | options: AcpPatchedProtocolOptions, |
| ... | ... | @@ -109,6 +120,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi |
| 109 | 120 | const offerOutgoing = Effect.fn("offerOutgoing")(function* ( |
| 110 | 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 | 128 | yield* logProtocol({ |
| 113 | 129 | direction: "outgoing", |
| 114 | 130 | stage: "decoded", |
| ... | ... | @@ -521,17 +537,29 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi |
| 521 | 537 | supportsSpanPropagation: true, |
| 522 | 538 | }); |
| 523 | 539 | |
| 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 | 543 | const sendNotification = Effect.fn("sendNotification")(function* ( |
| 525 | 544 | method: string, |
| 526 | 545 | payload: unknown, |
| 527 | 546 | ) { |
| 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 }, | |
| 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 | }); |
| 536 | 564 | |
| 537 | 565 | const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { |