| 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,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 | const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ | 298 | const 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 | ...(initialGrokReasoningEffort | 302 | _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> = [ |
| 307 | function modelState(): AcpSchema.SessionModelState { | 316 | function modelState(): AcpSchema.SessionModelState { |
| 308 | const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) | 317 | const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) |
| 309 | ? currentModelId | 318 | ? 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 | ); |
| 722 | 722 | ||
| 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"); |
| 731 | 731 | ||
| 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 | ); |
| 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 | 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 that | 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 | 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 agent | 1479 | // 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 turn | 1480 | // 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 prompt | 1485 | // Count this prompt immediately so a superseded in-flight prompt |
| 1475 | // resolving from here on does not settle the turn; decremented on | 1486 | // 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 can | 1491 | // 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 | } |
| 1614 | 1635 | ||
| 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); |
| 1645 | 1669 | ||
| 1646 | return yield* Effect.gen(function* () { | 1670 | return yield* Effect.gen(function* () { |
| 1647 | const result = yield* prepared.acp | 1671 | 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 | ); | ||
| 1672 | 1756 | ||
| 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. | ||
| 2 | import * as NodePath from "node:path"; | ||
| 3 | import * as NodeURL from "node:url"; | ||
| 4 | |||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| ... | @@ -8,11 +12,74 @@ import { GrokSettings } from "@t3tools/contracts"; | ... | @@ -8,11 +12,74 @@ import { GrokSettings } from "@t3tools/contracts"; |
| 8 | 12 | ||
| 9 | import { | 13 | import { |
| 10 | buildGrokModelCapabilities, | 14 | buildGrokModelCapabilities, |
| 15 | buildGrokModelsFromSessionModelState, | ||
| 11 | buildInitialGrokProviderSnapshot, | 16 | buildInitialGrokProviderSnapshot, |
| 12 | checkGrokProviderStatus, | 17 | checkGrokProviderStatus, |
| 18 | parseGrokModelsCliOutput, | ||
| 13 | } from "./GrokProvider.ts"; | 19 | } from "./GrokProvider.ts"; |
| 14 | 20 | ||
| 15 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 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 | describe("buildGrokModelCapabilities", () => { | 84 | describe("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 | ); |
| 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 | 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 | ); | ||
| 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 | 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 | ); |
| 286 | 412 | ||
| 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"; |
| 7 | import type * as EffectAcpSchema from "effect-acp/schema"; | 8 | import 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"; |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 19 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 19 | 20 | ||
| 20 | import { | 21 | import { |
| 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"; |
| 32 | import { | 34 | import { |
| 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"; |
| 40 | import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts"; | ||
| 37 | import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; | 41 | import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; |
| 38 | 42 | ||
| 39 | const GROK_PRESENTATION = { | 43 | const GROK_PRESENTATION = { |
| ... | @@ -46,11 +50,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ... | @@ -46,11 +50,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 46 | }); | 50 | }); |
| 47 | 51 | ||
| 48 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | 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 | const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | 57 | const 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 | } |
| 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 | 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.availableModels | 219 | 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 | } |
| 227 | 236 | ||
| 228 | const discoverGrokModelsViaAcp = ( | 237 | export 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 | */ | ||
| 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(); | 279 | } |
| 242 | return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); | 280 | return { authenticated, models }; |
| 243 | }).pipe(Effect.scoped); | 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 | 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 | }); |
| 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 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( | 328 | export 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 | } |
| 290 | 355 | ||
| 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 | } |
| 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 | const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); | 456 | const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); |
| 358 | 457 | ||
| 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; | ||
| 406 | 492 | ||
| 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 | }); |
| 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 | export function syntheticLoadSessionResponseFromInitialize( | 728 | export 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; |
| 724 | 735 | ||
| 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/extensibility | 182 | * @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/prompt | 212 | * @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 | ); |
| 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 | 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 | ); | ||
| 552 | 566 | ||
| 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.agent | 795 | // 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 | ); |
| 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 | 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 | }); |
| 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 | export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { | 107 | export 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 | } |
| 106 | 112 | ||
| 107 | const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; | 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,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 = reasoningProvided | 169 | 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 | runtime | 447 | 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 | } |
| 456 | 456 | ||
| 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">; |
| 471 | 471 | ||
| 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 |
| 39 | Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No | 39 | Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No |
| 40 | orchestration, contract, or client change is required for the common case. | 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 | ## OpenCode server ownership and catalog | 53 | ## OpenCode server ownership and catalog |
| 43 | 54 | ||
| 44 | Each OpenCode provider instance owns one lazy local server for catalog discovery and | 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,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) => { |
| 229 | 229 | ||
| 230 | yield* transport.notify("session/cancel", { sessionId: "session-1" }); | 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 | 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 | }); |
| 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 | 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); |
| 313 | 314 | ||
| 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 @@ |
| 1 | import * as Cause from "effect/Cause"; | 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Deferred from "effect/Deferred"; | 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as Queue from "effect/Queue"; | 5 | import * as Queue from "effect/Queue"; |
| 5 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 6 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| ... | @@ -77,6 +78,16 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( | ... | @@ -77,6 +78,16 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( |
| 77 | ); | 78 | ); |
| 78 | const parserFactory = RpcSerialization.ndJsonRpc(); | 79 | const parserFactory = RpcSerialization.ndJsonRpc(); |
| 79 | const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; | 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 | export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( | 92 | export 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 | }); |
| 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 | 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 | }); |
| 536 | 564 | ||
| 537 | const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { | 565 | const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { |