| author | |
| committer | |
| log | 38ea6d483ebf0a8c76f7105824f79489871057e5 |
| tree | 02c12fd27d669a9488c0252e5faf892cbe7839a0 |
| parent | 0e4a43519fe4fa85ce60aa8d6d53c067e56d2fca |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>40 files changed, 3673 insertions(+), 22 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+3| ... | @@ -35,6 +35,7 @@ import { ProjectionCheckpointRepository } from "../src/persistence/Services/Proj | ... | @@ -35,6 +35,7 @@ import { ProjectionCheckpointRepository } from "../src/persistence/Services/Proj |
| 35 | import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; | 35 | import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; |
| 36 | import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; | 36 | import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; |
| 37 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | 37 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| 38 | import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts"; | ||
| 38 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | 39 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; |
| 39 | import { ServerSettingsService } from "../src/serverSettings.ts"; | 40 | import { ServerSettingsService } from "../src/serverSettings.ts"; |
| 40 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | 41 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; |
| ... | @@ -293,6 +294,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -293,6 +294,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 293 | Layer.provide(AnalyticsService.layerTest), | 294 | Layer.provide(AnalyticsService.layerTest), |
| 294 | Layer.provide(providerEventLoggersLayer), | 295 | Layer.provide(providerEventLoggersLayer), |
| 295 | ); | 296 | ); |
| 297 | const providerRegistryLayer = makeProviderRegistryLayer(); | ||
| 296 | 298 | ||
| 297 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); | 299 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); |
| 298 | const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; | 300 | const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; |
| ... | @@ -375,6 +377,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -375,6 +377,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 375 | const layer = Layer.empty.pipe( | 377 | const layer = Layer.empty.pipe( |
| 376 | Layer.provideMerge(runtimeServicesLayer), | 378 | Layer.provideMerge(runtimeServicesLayer), |
| 377 | Layer.provideMerge(orchestrationReactorLayer), | 379 | Layer.provideMerge(orchestrationReactorLayer), |
| 380 | Layer.provideMerge(providerRegistryLayer), | ||
| 378 | Layer.provide(persistenceLayer), | 381 | Layer.provide(persistenceLayer), |
| 379 | Layer.provideMerge(RepositoryIdentityResolverLive), | 382 | Layer.provideMerge(RepositoryIdentityResolverLive), |
| 380 | Layer.provideMerge(ServerSettingsService.layerTest()), | 383 | Layer.provideMerge(ServerSettingsService.layerTest()), |
apps/server/scripts/acp-mock-agent.ts+83-3| ... | @@ -18,9 +18,15 @@ const emitInterleavedAssistantToolCalls = | ... | @@ -18,9 +18,15 @@ const emitInterleavedAssistantToolCalls = |
| 18 | process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; | 18 | process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; |
| 19 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; | 19 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; |
| 20 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; | 20 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; |
| 21 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; | ||
| 21 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; | 22 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; |
| 22 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; | 23 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 23 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; | 24 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; |
| 25 | const permissionOptionIds = { | ||
| 26 | allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", | ||
| 27 | allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", | ||
| 28 | rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", | ||
| 29 | }; | ||
| 24 | const sessionId = "mock-session-1"; | 30 | const sessionId = "mock-session-1"; |
| 25 | 31 | ||
| 26 | let currentModeId = "ask"; | 32 | let currentModeId = "ask"; |
| ... | @@ -237,6 +243,21 @@ function modeState(): AcpSchema.SessionModeState { | ... | @@ -237,6 +243,21 @@ function modeState(): AcpSchema.SessionModeState { |
| 237 | }; | 243 | }; |
| 238 | } | 244 | } |
| 239 | 245 | ||
| 246 | const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ | ||
| 247 | { modelId: "grok-build", name: "Grok Build" }, | ||
| 248 | { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, | ||
| 249 | ]; | ||
| 250 | |||
| 251 | function modelState(): AcpSchema.SessionModelState { | ||
| 252 | const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) | ||
| 253 | ? currentModelId | ||
| 254 | : "grok-build"; | ||
| 255 | return { | ||
| 256 | currentModelId: modelId, | ||
| 257 | availableModels: grokAcpModels, | ||
| 258 | }; | ||
| 259 | } | ||
| 260 | |||
| 240 | const program = Effect.gen(function* () { | 261 | const program = Effect.gen(function* () { |
| 241 | const agent = yield* EffectAcpAgent.AcpAgent; | 262 | const agent = yield* EffectAcpAgent.AcpAgent; |
| 242 | 263 | ||
| ... | @@ -257,6 +278,7 @@ const program = Effect.gen(function* () { | ... | @@ -257,6 +278,7 @@ const program = Effect.gen(function* () { |
| 257 | Effect.succeed({ | 278 | Effect.succeed({ |
| 258 | sessionId, | 279 | sessionId, |
| 259 | modes: modeState(), | 280 | modes: modeState(), |
| 281 | models: modelState(), | ||
| 260 | configOptions: configOptions(), | 282 | configOptions: configOptions(), |
| 261 | }), | 283 | }), |
| 262 | ); | 284 | ); |
| ... | @@ -273,11 +295,28 @@ const program = Effect.gen(function* () { | ... | @@ -273,11 +295,28 @@ const program = Effect.gen(function* () { |
| 273 | .pipe( | 295 | .pipe( |
| 274 | Effect.as({ | 296 | Effect.as({ |
| 275 | modes: modeState(), | 297 | modes: modeState(), |
| 298 | models: modelState(), | ||
| 276 | configOptions: configOptions(), | 299 | configOptions: configOptions(), |
| 277 | }), | 300 | }), |
| 278 | ), | 301 | ), |
| 279 | ); | 302 | ); |
| 280 | 303 | ||
| 304 | yield* agent.handleSetSessionModel((request) => | ||
| 305 | Effect.gen(function* () { | ||
| 306 | if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { | ||
| 307 | return yield* AcpError.AcpRequestError.invalidParams( | ||
| 308 | `Unknown mock model id: ${request.modelId}`, | ||
| 309 | { | ||
| 310 | method: "session/set_model", | ||
| 311 | params: request, | ||
| 312 | }, | ||
| 313 | ); | ||
| 314 | } | ||
| 315 | currentModelId = request.modelId; | ||
| 316 | return {}; | ||
| 317 | }), | ||
| 318 | ); | ||
| 319 | |||
| 281 | yield* agent.handleSetSessionConfigOption((request) => | 320 | yield* agent.handleSetSessionConfigOption((request) => |
| 282 | Effect.gen(function* () { | 321 | Effect.gen(function* () { |
| 283 | if (exitOnSetConfigOption) { | 322 | if (exitOnSetConfigOption) { |
| ... | @@ -419,9 +458,13 @@ const program = Effect.gen(function* () { | ... | @@ -419,9 +458,13 @@ const program = Effect.gen(function* () { |
| 419 | ], | 458 | ], |
| 420 | }, | 459 | }, |
| 421 | options: [ | 460 | options: [ |
| 422 | { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, | 461 | { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, |
| 423 | { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, | 462 | { |
| 424 | { optionId: "reject-once", name: "Reject", kind: "reject_once" }, | 463 | optionId: permissionOptionIds.allowAlways, |
| 464 | name: "Allow always", | ||
| 465 | kind: "allow_always", | ||
| 466 | }, | ||
| 467 | { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, | ||
| 425 | ], | 468 | ], |
| 426 | }); | 469 | }); |
| 427 | 470 | ||
| ... | @@ -514,6 +557,43 @@ const program = Effect.gen(function* () { | ... | @@ -514,6 +557,43 @@ const program = Effect.gen(function* () { |
| 514 | return { stopReason: "end_turn" }; | 557 | return { stopReason: "end_turn" }; |
| 515 | } | 558 | } |
| 516 | 559 | ||
| 560 | if (emitXAiAskUserQuestion) { | ||
| 561 | const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { | ||
| 562 | method: "x.ai/ask_user_question", | ||
| 563 | params: { | ||
| 564 | sessionId: requestedSessionId, | ||
| 565 | toolCallId: "ask-user-question-tool-call-1", | ||
| 566 | questions: [ | ||
| 567 | { | ||
| 568 | question: "Which scope should Grok use?", | ||
| 569 | multiSelect: null, | ||
| 570 | options: [ | ||
| 571 | { label: "Workspace", description: "Use the current workspace" }, | ||
| 572 | { label: "Session", description: "Only use this session" }, | ||
| 573 | ], | ||
| 574 | }, | ||
| 575 | ], | ||
| 576 | mode: "default", | ||
| 577 | }, | ||
| 578 | }); | ||
| 579 | if (typeof result !== "object" || result === null || !("outcome" in result)) { | ||
| 580 | throw new Error("Expected _x.ai/ask_user_question response outcome."); | ||
| 581 | } | ||
| 582 | if (result.outcome === "cancelled") { | ||
| 583 | return { stopReason: "end_turn" }; | ||
| 584 | } | ||
| 585 | if ( | ||
| 586 | result.outcome !== "accepted" || | ||
| 587 | !("answers" in result) || | ||
| 588 | typeof result.answers !== "object" || | ||
| 589 | result.answers === null | ||
| 590 | ) { | ||
| 591 | throw new Error("Expected accepted _x.ai/ask_user_question response answers."); | ||
| 592 | } | ||
| 593 | |||
| 594 | return { stopReason: "end_turn" }; | ||
| 595 | } | ||
| 596 | |||
| 517 | yield* agent.client.sessionUpdate({ | 597 | yield* agent.client.sessionUpdate({ |
| 518 | sessionId: requestedSessionId, | 598 | sessionId: requestedSessionId, |
| 519 | update: { | 599 | update: { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+76| ... | @@ -40,6 +40,7 @@ import { | ... | @@ -40,6 +40,7 @@ import { |
| 40 | ProviderService, | 40 | ProviderService, |
| 41 | type ProviderServiceShape, | 41 | type ProviderServiceShape, |
| 42 | } from "../../provider/Services/ProviderService.ts"; | 42 | } from "../../provider/Services/ProviderService.ts"; |
| 43 | import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; | ||
| 43 | import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; | 44 | import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; |
| 44 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | 45 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; |
| 45 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 46 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| ... | @@ -142,6 +143,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -142,6 +143,7 @@ describe("ProviderCommandReactor", () => { |
| 142 | readonly baseDir?: string; | 143 | readonly baseDir?: string; |
| 143 | readonly threadModelSelection?: ModelSelection; | 144 | readonly threadModelSelection?: ModelSelection; |
| 144 | readonly sessionModelSwitch?: "unsupported" | "in-session"; | 145 | readonly sessionModelSwitch?: "unsupported" | "in-session"; |
| 146 | readonly requiresNewThreadForModelChange?: boolean; | ||
| 145 | }) { | 147 | }) { |
| 146 | const now = "2026-01-01T00:00:00.000Z"; | 148 | const now = "2026-01-01T00:00:00.000Z"; |
| 147 | const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); | 149 | const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); |
| ... | @@ -280,6 +282,14 @@ describe("ProviderCommandReactor", () => { | ... | @@ -280,6 +282,14 @@ describe("ProviderCommandReactor", () => { |
| 280 | }), | 282 | }), |
| 281 | ), | 283 | ), |
| 282 | ); | 284 | ); |
| 285 | const providerSnapshots = [ | ||
| 286 | { | ||
| 287 | instanceId: modelSelection.instanceId, | ||
| 288 | ...(input?.requiresNewThreadForModelChange === true | ||
| 289 | ? { requiresNewThreadForModelChange: true } | ||
| 290 | : {}), | ||
| 291 | }, | ||
| 292 | ]; | ||
| 283 | 293 | ||
| 284 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | 294 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 285 | const service: ProviderServiceShape = { | 295 | const service: ProviderServiceShape = { |
| ... | @@ -335,6 +345,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -335,6 +345,7 @@ describe("ProviderCommandReactor", () => { |
| 335 | Layer.provideMerge(orchestrationLayer), | 345 | Layer.provideMerge(orchestrationLayer), |
| 336 | Layer.provideMerge(projectionSnapshotLayer), | 346 | Layer.provideMerge(projectionSnapshotLayer), |
| 337 | Layer.provideMerge(Layer.succeed(ProviderService, service)), | 347 | Layer.provideMerge(Layer.succeed(ProviderService, service)), |
| 348 | Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), | ||
| 338 | Layer.provideMerge( | 349 | Layer.provideMerge( |
| 339 | Layer.mock(GitWorkflowService)({ | 350 | Layer.mock(GitWorkflowService)({ |
| 340 | renameBranch, | 351 | renameBranch, |
| ... | @@ -879,6 +890,71 @@ describe("ProviderCommandReactor", () => { | ... | @@ -879,6 +890,71 @@ describe("ProviderCommandReactor", () => { |
| 879 | }); | 890 | }); |
| 880 | }); | 891 | }); |
| 881 | 892 | ||
| 893 | it("rejects changing models after start when the provider requires a new thread", async () => { | ||
| 894 | const harness = await createHarness({ requiresNewThreadForModelChange: true }); | ||
| 895 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 896 | |||
| 897 | await Effect.runPromise( | ||
| 898 | harness.engine.dispatch({ | ||
| 899 | type: "thread.turn.start", | ||
| 900 | commandId: CommandId.make("cmd-turn-start-restricted-1"), | ||
| 901 | threadId: ThreadId.make("thread-1"), | ||
| 902 | message: { | ||
| 903 | messageId: asMessageId("user-message-restricted-1"), | ||
| 904 | role: "user", | ||
| 905 | text: "first", | ||
| 906 | attachments: [], | ||
| 907 | }, | ||
| 908 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 909 | runtimeMode: "approval-required", | ||
| 910 | createdAt: now, | ||
| 911 | }), | ||
| 912 | ); | ||
| 913 | |||
| 914 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | ||
| 915 | |||
| 916 | await Effect.runPromise( | ||
| 917 | harness.engine.dispatch({ | ||
| 918 | type: "thread.turn.start", | ||
| 919 | commandId: CommandId.make("cmd-turn-start-restricted-2"), | ||
| 920 | threadId: ThreadId.make("thread-1"), | ||
| 921 | message: { | ||
| 922 | messageId: asMessageId("user-message-restricted-2"), | ||
| 923 | role: "user", | ||
| 924 | text: "second", | ||
| 925 | attachments: [], | ||
| 926 | }, | ||
| 927 | modelSelection: { | ||
| 928 | instanceId: ProviderInstanceId.make("codex"), | ||
| 929 | model: "gpt-5.1-codex", | ||
| 930 | }, | ||
| 931 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 932 | runtimeMode: "approval-required", | ||
| 933 | createdAt: now, | ||
| 934 | }), | ||
| 935 | ); | ||
| 936 | |||
| 937 | await waitFor(async () => { | ||
| 938 | const readModel = await harness.readModel(); | ||
| 939 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 940 | return ( | ||
| 941 | thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? | ||
| 942 | false | ||
| 943 | ); | ||
| 944 | }); | ||
| 945 | |||
| 946 | expect(harness.sendTurn).toHaveBeenCalledTimes(1); | ||
| 947 | const readModel = await harness.readModel(); | ||
| 948 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 949 | expect( | ||
| 950 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | ||
| 951 | ).toMatchObject({ | ||
| 952 | payload: { | ||
| 953 | detail: expect.stringContaining("cannot switch models after the conversation has started"), | ||
| 954 | }, | ||
| 955 | }); | ||
| 956 | }); | ||
| 957 | |||
| 882 | it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => { | 958 | it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => { |
| 883 | const harness = await createHarness({ | 959 | const harness = await createHarness({ |
| 884 | threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, | 960 | threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+48| ... | @@ -31,6 +31,7 @@ import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; | ... | @@ -31,6 +31,7 @@ import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; |
| 31 | import type { ProviderServiceError } from "../../provider/Errors.ts"; | 31 | import type { ProviderServiceError } from "../../provider/Errors.ts"; |
| 32 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; | 32 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 33 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 33 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 34 | import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; | ||
| 34 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 35 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 35 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | 36 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 36 | import { | 37 | import { |
| ... | @@ -180,6 +181,7 @@ const make = Effect.gen(function* () { | ... | @@ -180,6 +181,7 @@ const make = Effect.gen(function* () { |
| 180 | const orchestrationEngine = yield* OrchestrationEngineService; | 181 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 181 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 182 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 182 | const providerService = yield* ProviderService; | 183 | const providerService = yield* ProviderService; |
| 184 | const providerRegistry = yield* ProviderRegistry; | ||
| 183 | const gitWorkflow = yield* GitWorkflowService; | 185 | const gitWorkflow = yield* GitWorkflowService; |
| 184 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 186 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 185 | const textGeneration = yield* TextGeneration; | 187 | const textGeneration = yield* TextGeneration; |
| ... | @@ -305,6 +307,38 @@ const make = Effect.gen(function* () { | ... | @@ -305,6 +307,38 @@ const make = Effect.gen(function* () { |
| 305 | .pipe(Effect.map(Option.getOrUndefined)); | 307 | .pipe(Effect.map(Option.getOrUndefined)); |
| 306 | }); | 308 | }); |
| 307 | 309 | ||
| 310 | const rejectStartedThreadModelChangeIfRequired = Effect.fnUntraced(function* (input: { | ||
| 311 | readonly threadId: ThreadId; | ||
| 312 | readonly currentModelSelection: ModelSelection; | ||
| 313 | readonly requestedModelSelection: ModelSelection | undefined; | ||
| 314 | }) { | ||
| 315 | const requestedModelSelection = input.requestedModelSelection; | ||
| 316 | if ( | ||
| 317 | requestedModelSelection === undefined || | ||
| 318 | (input.currentModelSelection.instanceId === requestedModelSelection.instanceId && | ||
| 319 | input.currentModelSelection.model === requestedModelSelection.model) | ||
| 320 | ) { | ||
| 321 | return; | ||
| 322 | } | ||
| 323 | const providers = yield* providerRegistry.getProviders; | ||
| 324 | const requiresNewThread = | ||
| 325 | providers.find((snapshot) => snapshot.instanceId === input.currentModelSelection.instanceId) | ||
| 326 | ?.requiresNewThreadForModelChange === true || | ||
| 327 | providers.find((snapshot) => snapshot.instanceId === requestedModelSelection.instanceId) | ||
| 328 | ?.requiresNewThreadForModelChange === true; | ||
| 329 | if (!requiresNewThread) { | ||
| 330 | return; | ||
| 331 | } | ||
| 332 | return yield* new ProviderAdapterRequestError({ | ||
| 333 | provider: providerErrorLabelFromInstanceHint({ | ||
| 334 | instanceId: String(requestedModelSelection.instanceId), | ||
| 335 | modelSelectionInstanceId: String(input.currentModelSelection.instanceId), | ||
| 336 | }), | ||
| 337 | method: "thread.turn.start", | ||
| 338 | detail: `Thread '${input.threadId}' cannot switch models after the conversation has started. Start a new thread to use '${requestedModelSelection.model}'.`, | ||
| 339 | }); | ||
| 340 | }); | ||
| 341 | |||
| 308 | const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* ( | 342 | const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* ( |
| 309 | threadId: ThreadId, | 343 | threadId: ThreadId, |
| 310 | createdAt: string, | 344 | createdAt: string, |
| ... | @@ -384,6 +418,20 @@ const make = Effect.gen(function* () { | ... | @@ -384,6 +418,20 @@ const make = Effect.gen(function* () { |
| 384 | }); | 418 | }); |
| 385 | } | 419 | } |
| 386 | const preferredProvider: ProviderDriverKind = desiredDriverKind; | 420 | const preferredProvider: ProviderDriverKind = desiredDriverKind; |
| 421 | if (thread.session !== null) { | ||
| 422 | yield* rejectStartedThreadModelChangeIfRequired({ | ||
| 423 | threadId, | ||
| 424 | currentModelSelection: | ||
| 425 | activeSession?.model !== undefined | ||
| 426 | ? { | ||
| 427 | ...thread.modelSelection, | ||
| 428 | instanceId: currentInstanceId, | ||
| 429 | model: activeSession.model, | ||
| 430 | } | ||
| 431 | : thread.modelSelection, | ||
| 432 | requestedModelSelection, | ||
| 433 | }); | ||
| 434 | } | ||
| 387 | if ( | 435 | if ( |
| 388 | thread.session !== null && | 436 | thread.session !== null && |
| 389 | requestedModelSelection !== undefined && | 437 | requestedModelSelection !== undefined && |
apps/server/src/provider/Drivers/GrokDriver.ts created+153| ... | @@ -0,0 +1,153 @@ | ||
| 1 | import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import { HttpClient } from "effect/unstable/http"; | ||
| 10 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 11 | |||
| 12 | import { ServerConfig } from "../../config.ts"; | ||
| 13 | import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; | ||
| 14 | import { ProviderDriverError } from "../Errors.ts"; | ||
| 15 | import { makeGrokAdapter } from "../Layers/GrokAdapter.ts"; | ||
| 16 | import { | ||
| 17 | buildInitialGrokProviderSnapshot, | ||
| 18 | checkGrokProviderStatus, | ||
| 19 | enrichGrokSnapshot, | ||
| 20 | } from "../Layers/GrokProvider.ts"; | ||
| 21 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | ||
| 22 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 23 | import { | ||
| 24 | defaultProviderContinuationIdentity, | ||
| 25 | type ProviderDriver, | ||
| 26 | type ProviderInstance, | ||
| 27 | } from "../ProviderDriver.ts"; | ||
| 28 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | ||
| 29 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | ||
| 30 | import { | ||
| 31 | makeManualOnlyProviderMaintenanceCapabilities, | ||
| 32 | makeStaticProviderMaintenanceResolver, | ||
| 33 | resolveProviderMaintenanceCapabilitiesEffect, | ||
| 34 | } from "../providerMaintenance.ts"; | ||
| 35 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | ||
| 36 | |||
| 37 | const DRIVER_KIND = ProviderDriverKind.make("grok"); | ||
| 38 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| 39 | const UPDATE = makeStaticProviderMaintenanceResolver( | ||
| 40 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 41 | provider: DRIVER_KIND, | ||
| 42 | packageName: null, | ||
| 43 | }), | ||
| 44 | ); | ||
| 45 | |||
| 46 | export type GrokDriverEnv = | ||
| 47 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 48 | | Crypto.Crypto | ||
| 49 | | FileSystem.FileSystem | ||
| 50 | | HttpClient.HttpClient | ||
| 51 | | Path.Path | ||
| 52 | | ProviderEventLoggers | ||
| 53 | | ServerConfig; | ||
| 54 | |||
| 55 | const withInstanceIdentity = | ||
| 56 | (input: { | ||
| 57 | readonly instanceId: ProviderInstance["instanceId"]; | ||
| 58 | readonly displayName: string | undefined; | ||
| 59 | readonly accentColor: string | undefined; | ||
| 60 | readonly continuationGroupKey: string; | ||
| 61 | }) => | ||
| 62 | (snapshot: ServerProviderDraft): ServerProvider => ({ | ||
| 63 | ...snapshot, | ||
| 64 | instanceId: input.instanceId, | ||
| 65 | driver: DRIVER_KIND, | ||
| 66 | ...(input.displayName ? { displayName: input.displayName } : {}), | ||
| 67 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | ||
| 68 | continuation: { groupKey: input.continuationGroupKey }, | ||
| 69 | }); | ||
| 70 | |||
| 71 | export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | ||
| 72 | driverKind: DRIVER_KIND, | ||
| 73 | metadata: { | ||
| 74 | displayName: "Grok", | ||
| 75 | supportsMultipleInstances: true, | ||
| 76 | }, | ||
| 77 | configSchema: GrokSettings, | ||
| 78 | defaultConfig: (): GrokSettings => decodeGrokSettings({}), | ||
| 79 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 82 | const httpClient = yield* HttpClient.HttpClient; | ||
| 83 | const eventLoggers = yield* ProviderEventLoggers; | ||
| 84 | const processEnv = mergeProviderInstanceEnvironment(environment); | ||
| 85 | const continuationIdentity = defaultProviderContinuationIdentity({ | ||
| 86 | driverKind: DRIVER_KIND, | ||
| 87 | instanceId, | ||
| 88 | }); | ||
| 89 | const stampIdentity = withInstanceIdentity({ | ||
| 90 | instanceId, | ||
| 91 | displayName, | ||
| 92 | accentColor, | ||
| 93 | continuationGroupKey: continuationIdentity.continuationKey, | ||
| 94 | }); | ||
| 95 | const effectiveConfig = { ...config, enabled } satisfies GrokSettings; | ||
| 96 | const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | ||
| 97 | binaryPath: effectiveConfig.binaryPath, | ||
| 98 | env: processEnv, | ||
| 99 | }); | ||
| 100 | |||
| 101 | const adapter = yield* makeGrokAdapter(effectiveConfig, { | ||
| 102 | environment: processEnv, | ||
| 103 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | ||
| 104 | instanceId, | ||
| 105 | }); | ||
| 106 | const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); | ||
| 107 | |||
| 108 | const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( | ||
| 109 | Effect.map(stampIdentity), | ||
| 110 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 111 | ); | ||
| 112 | |||
| 113 | const snapshot = yield* makeManagedServerProvider<GrokSettings>({ | ||
| 114 | maintenanceCapabilities, | ||
| 115 | getSettings: Effect.succeed(effectiveConfig), | ||
| 116 | streamSettings: Stream.never, | ||
| 117 | haveSettingsChanged: () => false, | ||
| 118 | initialSnapshot: (settings) => | ||
| 119 | buildInitialGrokProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), | ||
| 120 | checkProvider, | ||
| 121 | enrichSnapshot: ({ snapshot: currentSnapshot, publishSnapshot }) => | ||
| 122 | enrichGrokSnapshot({ | ||
| 123 | snapshot: currentSnapshot, | ||
| 124 | maintenanceCapabilities, | ||
| 125 | publishSnapshot, | ||
| 126 | httpClient, | ||
| 127 | }), | ||
| 128 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| 129 | }).pipe( | ||
| 130 | Effect.mapError( | ||
| 131 | (cause) => | ||
| 132 | new ProviderDriverError({ | ||
| 133 | driver: DRIVER_KIND, | ||
| 134 | instanceId, | ||
| 135 | detail: `Failed to build Grok snapshot: ${cause.message ?? String(cause)}`, | ||
| 136 | cause, | ||
| 137 | }), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | |||
| 141 | return { | ||
| 142 | instanceId, | ||
| 143 | driverKind: DRIVER_KIND, | ||
| 144 | continuationIdentity, | ||
| 145 | displayName, | ||
| 146 | accentColor, | ||
| 147 | enabled, | ||
| 148 | snapshot, | ||
| 149 | adapter, | ||
| 150 | textGeneration, | ||
| 151 | } satisfies ProviderInstance; | ||
| 152 | }), | ||
| 153 | }; | ||
apps/server/src/provider/Layers/GrokAdapter.test.ts created+376| ... | @@ -0,0 +1,376 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as path from "node:path"; | ||
| 3 | import * as os from "node:os"; | ||
| 4 | import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; | ||
| 5 | import { fileURLToPath } from "node:url"; | ||
| 6 | |||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { assert, it } from "@effect/vitest"; | ||
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Schema from "effect/Schema"; | ||
| 14 | import * as Stream from "effect/Stream"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | ApprovalRequestId, | ||
| 18 | GrokSettings, | ||
| 19 | ProviderDriverKind, | ||
| 20 | ThreadId, | ||
| 21 | ProviderInstanceId, | ||
| 22 | type ProviderRuntimeEvent, | ||
| 23 | } from "@t3tools/contracts"; | ||
| 24 | |||
| 25 | import { ServerConfig } from "../../config.ts"; | ||
| 26 | import { makeGrokAdapter } from "./GrokAdapter.ts"; | ||
| 27 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | ||
| 28 | |||
| 29 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| 30 | const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | ||
| 31 | const mockAgentCommand = process.execPath; | ||
| 32 | |||
| 33 | async function makeMockGrokWrapper(extraEnv?: Record<string, string>) { | ||
| 34 | const dir = await mkdtemp(path.join(os.tmpdir(), "grok-acp-mock-")); | ||
| 35 | const wrapperPath = path.join(dir, "fake-grok.sh"); | ||
| 36 | const envExports = Object.entries(extraEnv ?? {}) | ||
| 37 | .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) | ||
| 38 | .join("\n"); | ||
| 39 | const script = `#!/bin/sh | ||
| 40 | ${envExports} | ||
| 41 | exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" | ||
| 42 | `; | ||
| 43 | await writeFile(wrapperPath, script, "utf8"); | ||
| 44 | await chmod(wrapperPath, 0o755); | ||
| 45 | return wrapperPath; | ||
| 46 | } | ||
| 47 | |||
| 48 | async function waitForFileContent(filePath: string, attempts = 40): Promise<string> { | ||
| 49 | const readAttempt = async (remainingAttempts: number): Promise<string> => { | ||
| 50 | if (remainingAttempts <= 0) { | ||
| 51 | throw new Error(`Timed out waiting for file content at ${filePath}`); | ||
| 52 | } | ||
| 53 | try { | ||
| 54 | const raw = await readFile(filePath, "utf8"); | ||
| 55 | if (raw.trim().length > 0) { | ||
| 56 | return raw; | ||
| 57 | } | ||
| 58 | } catch {} | ||
| 59 | await Effect.runPromise(Effect.sleep("25 millis")); | ||
| 60 | return readAttempt(remainingAttempts - 1); | ||
| 61 | }; | ||
| 62 | return readAttempt(attempts); | ||
| 63 | } | ||
| 64 | |||
| 65 | async function readJsonLines(filePath: string) { | ||
| 66 | const raw = await readFile(filePath, "utf8"); | ||
| 67 | return raw | ||
| 68 | .split("\n") | ||
| 69 | .map((line) => line.trim()) | ||
| 70 | .filter((line) => line.length > 0) | ||
| 71 | .map((line) => JSON.parse(line) as Record<string, unknown>); | ||
| 72 | } | ||
| 73 | |||
| 74 | const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 75 | prefix: "t3code-grok-adapter-test-", | ||
| 76 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 77 | |||
| 78 | const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) => | ||
| 79 | makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); | ||
| 80 | |||
| 81 | it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ||
| 82 | it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const threadId = ThreadId.make("grok-mock-thread"); | ||
| 85 | const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); | ||
| 86 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 87 | |||
| 88 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 89 | const turnCompleted = yield* Deferred.make<void>(); | ||
| 90 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 91 | Effect.sync(() => { | ||
| 92 | runtimeEvents.push(event); | ||
| 93 | }).pipe( | ||
| 94 | Effect.andThen( | ||
| 95 | event.type === "turn.completed" | ||
| 96 | ? Deferred.succeed(turnCompleted, undefined) | ||
| 97 | : Effect.void, | ||
| 98 | ), | ||
| 99 | ), | ||
| 100 | ).pipe(Effect.forkChild); | ||
| 101 | |||
| 102 | const session = yield* adapter.startSession({ | ||
| 103 | threadId, | ||
| 104 | provider: ProviderDriverKind.make("grok"), | ||
| 105 | cwd: process.cwd(), | ||
| 106 | runtimeMode: "full-access", | ||
| 107 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-mock-alt" }, | ||
| 108 | }); | ||
| 109 | |||
| 110 | assert.equal(session.provider, "grok"); | ||
| 111 | assert.equal(session.model, "grok-mock-alt"); | ||
| 112 | assert.deepStrictEqual(session.resumeCursor, { | ||
| 113 | schemaVersion: 1, | ||
| 114 | sessionId: "mock-session-1", | ||
| 115 | }); | ||
| 116 | |||
| 117 | yield* adapter.sendTurn({ | ||
| 118 | threadId, | ||
| 119 | input: "hello grok", | ||
| 120 | attachments: [], | ||
| 121 | }); | ||
| 122 | |||
| 123 | yield* Deferred.await(turnCompleted); | ||
| 124 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 125 | const types = runtimeEvents.map((e) => e.type); | ||
| 126 | |||
| 127 | assert.includeMembers(types, [ | ||
| 128 | "session.started", | ||
| 129 | "session.state.changed", | ||
| 130 | "thread.started", | ||
| 131 | "turn.started", | ||
| 132 | "item.started", | ||
| 133 | "content.delta", | ||
| 134 | "turn.completed", | ||
| 135 | ] as const); | ||
| 136 | |||
| 137 | const delta = runtimeEvents.find((e) => e.type === "content.delta"); | ||
| 138 | assert.isDefined(delta); | ||
| 139 | if (delta?.type === "content.delta") { | ||
| 140 | assert.equal(delta.payload.delta, "hello from mock"); | ||
| 141 | } | ||
| 142 | |||
| 143 | yield* adapter.stopSession(threadId); | ||
| 144 | }), | ||
| 145 | ); | ||
| 146 | |||
| 147 | it.effect("closes the ACP child process when a session stops", () => | ||
| 148 | Effect.gen(function* () { | ||
| 149 | const threadId = ThreadId.make("grok-stop-session-close"); | ||
| 150 | const tempDir = yield* Effect.promise(() => | ||
| 151 | mkdtemp(path.join(os.tmpdir(), "grok-adapter-exit-log-")), | ||
| 152 | ); | ||
| 153 | const exitLogPath = path.join(tempDir, "exit.log"); | ||
| 154 | |||
| 155 | const wrapperPath = yield* Effect.promise(() => | ||
| 156 | makeMockGrokWrapper({ | ||
| 157 | T3_ACP_EXIT_LOG_PATH: exitLogPath, | ||
| 158 | }), | ||
| 159 | ); | ||
| 160 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 161 | |||
| 162 | yield* adapter.startSession({ | ||
| 163 | threadId, | ||
| 164 | provider: ProviderDriverKind.make("grok"), | ||
| 165 | cwd: process.cwd(), | ||
| 166 | runtimeMode: "full-access", | ||
| 167 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 168 | }); | ||
| 169 | |||
| 170 | yield* adapter.stopSession(threadId); | ||
| 171 | |||
| 172 | const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); | ||
| 173 | assert.include(exitLog, "SIGTERM"); | ||
| 174 | }), | ||
| 175 | ); | ||
| 176 | |||
| 177 | it.effect("rejects startSession when provider mismatches", () => | ||
| 178 | Effect.gen(function* () { | ||
| 179 | const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); | ||
| 180 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 181 | const threadId = ThreadId.make("grok-provider-mismatch"); | ||
| 182 | |||
| 183 | const error = yield* Effect.flip( | ||
| 184 | adapter.startSession({ | ||
| 185 | threadId, | ||
| 186 | provider: ProviderDriverKind.make("cursor"), | ||
| 187 | cwd: process.cwd(), | ||
| 188 | runtimeMode: "full-access", | ||
| 189 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | |||
| 193 | assert.equal(error._tag, "ProviderAdapterValidationError"); | ||
| 194 | }), | ||
| 195 | ); | ||
| 196 | |||
| 197 | it.effect("rejects sendTurn with empty input and no attachments", () => | ||
| 198 | Effect.gen(function* () { | ||
| 199 | const threadId = ThreadId.make("grok-empty-turn"); | ||
| 200 | |||
| 201 | const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); | ||
| 202 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 203 | |||
| 204 | yield* adapter.startSession({ | ||
| 205 | threadId, | ||
| 206 | provider: ProviderDriverKind.make("grok"), | ||
| 207 | cwd: process.cwd(), | ||
| 208 | runtimeMode: "full-access", | ||
| 209 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 210 | }); | ||
| 211 | |||
| 212 | const error = yield* Effect.flip( | ||
| 213 | adapter.sendTurn({ | ||
| 214 | threadId, | ||
| 215 | input: " ", | ||
| 216 | attachments: [], | ||
| 217 | }), | ||
| 218 | ); | ||
| 219 | |||
| 220 | assert.equal(error._tag, "ProviderAdapterValidationError"); | ||
| 221 | |||
| 222 | yield* adapter.stopSession(threadId); | ||
| 223 | }), | ||
| 224 | ); | ||
| 225 | |||
| 226 | it.effect("responds to ACP approvals using provider-supplied option ids", () => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | const threadId = ThreadId.make("grok-custom-approval-option-id"); | ||
| 229 | const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "grok-acp-"))); | ||
| 230 | const requestLogPath = path.join(tempDir, "requests.ndjson"); | ||
| 231 | const wrapperPath = yield* Effect.promise(() => | ||
| 232 | makeMockGrokWrapper({ | ||
| 233 | T3_ACP_REQUEST_LOG_PATH: requestLogPath, | ||
| 234 | T3_ACP_EMIT_TOOL_CALLS: "1", | ||
| 235 | T3_ACP_ALLOW_ONCE_OPTION_ID: "agent-defined-approval-id", | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 239 | const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 240 | event.type === "request.opened" | ||
| 241 | ? adapter.respondToRequest( | ||
| 242 | threadId, | ||
| 243 | ApprovalRequestId.make(String(event.requestId)), | ||
| 244 | "accept", | ||
| 245 | ) | ||
| 246 | : Effect.void, | ||
| 247 | ).pipe(Effect.forkChild); | ||
| 248 | |||
| 249 | yield* adapter.startSession({ | ||
| 250 | threadId, | ||
| 251 | provider: ProviderDriverKind.make("grok"), | ||
| 252 | cwd: process.cwd(), | ||
| 253 | runtimeMode: "approval-required", | ||
| 254 | }); | ||
| 255 | yield* adapter.sendTurn({ threadId, input: "approve this", attachments: [] }); | ||
| 256 | |||
| 257 | const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); | ||
| 258 | assert.isTrue( | ||
| 259 | requests.some( | ||
| 260 | (entry) => | ||
| 261 | !("method" in entry) && | ||
| 262 | typeof entry.result === "object" && | ||
| 263 | entry.result !== null && | ||
| 264 | "outcome" in entry.result && | ||
| 265 | typeof entry.result.outcome === "object" && | ||
| 266 | entry.result.outcome !== null && | ||
| 267 | "optionId" in entry.result.outcome && | ||
| 268 | entry.result.outcome.optionId === "agent-defined-approval-id", | ||
| 269 | ), | ||
| 270 | ); | ||
| 271 | |||
| 272 | yield* Fiber.interrupt(eventsFiber); | ||
| 273 | yield* adapter.stopSession(threadId); | ||
| 274 | }), | ||
| 275 | ); | ||
| 276 | |||
| 277 | it.effect("handles xAI ask_user_question extension requests", () => | ||
| 278 | Effect.gen(function* () { | ||
| 279 | const threadId = ThreadId.make("grok-xai-ask-user-question"); | ||
| 280 | const wrapperPath = yield* Effect.promise(() => | ||
| 281 | makeMockGrokWrapper({ T3_ACP_EMIT_XAI_ASK_USER_QUESTION: "1" }), | ||
| 282 | ); | ||
| 283 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 284 | const requested = | ||
| 285 | yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "user-input.requested" }>>(); | ||
| 286 | const resolved = | ||
| 287 | yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "user-input.resolved" }>>(); | ||
| 288 | |||
| 289 | const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { | ||
| 290 | if (String(event.threadId) !== String(threadId)) { | ||
| 291 | return Effect.void; | ||
| 292 | } | ||
| 293 | if (event.type === "user-input.requested") { | ||
| 294 | return Deferred.succeed(requested, event).pipe(Effect.ignore); | ||
| 295 | } | ||
| 296 | if (event.type === "user-input.resolved") { | ||
| 297 | return Deferred.succeed(resolved, event).pipe(Effect.ignore); | ||
| 298 | } | ||
| 299 | return Effect.void; | ||
| 300 | }).pipe(Effect.forkChild); | ||
| 301 | |||
| 302 | yield* adapter.startSession({ | ||
| 303 | threadId, | ||
| 304 | provider: ProviderDriverKind.make("grok"), | ||
| 305 | cwd: process.cwd(), | ||
| 306 | runtimeMode: "full-access", | ||
| 307 | }); | ||
| 308 | |||
| 309 | const sendTurnFiber = yield* adapter | ||
| 310 | .sendTurn({ threadId, input: "ask before continuing", attachments: [] }) | ||
| 311 | .pipe(Effect.forkChild); | ||
| 312 | |||
| 313 | const requestedEvent = yield* Deferred.await(requested); | ||
| 314 | assert.equal(requestedEvent.payload.questions.length, 1); | ||
| 315 | assert.equal(requestedEvent.payload.questions[0]?.id, "Which scope should Grok use?"); | ||
| 316 | assert.equal(requestedEvent.payload.questions[0]?.question, "Which scope should Grok use?"); | ||
| 317 | assert.equal(requestedEvent.raw?.method, "_x.ai/ask_user_question"); | ||
| 318 | |||
| 319 | yield* adapter.respondToUserInput( | ||
| 320 | threadId, | ||
| 321 | ApprovalRequestId.make(String(requestedEvent.requestId)), | ||
| 322 | { | ||
| 323 | "Which scope should Grok use?": "Workspace", | ||
| 324 | }, | ||
| 325 | ); | ||
| 326 | |||
| 327 | const resolvedEvent = yield* Deferred.await(resolved); | ||
| 328 | assert.deepEqual(resolvedEvent.payload.answers, { | ||
| 329 | "Which scope should Grok use?": "Workspace", | ||
| 330 | }); | ||
| 331 | yield* Fiber.join(sendTurnFiber); | ||
| 332 | |||
| 333 | yield* Fiber.interrupt(eventsFiber); | ||
| 334 | yield* adapter.stopSession(threadId); | ||
| 335 | }), | ||
| 336 | ); | ||
| 337 | |||
| 338 | it.effect("continues streaming events when native notification logging fails", () => | ||
| 339 | Effect.gen(function* () { | ||
| 340 | const threadId = ThreadId.make("grok-native-log-failure"); | ||
| 341 | const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); | ||
| 342 | const adapter = yield* makeTestAdapter(wrapperPath, { | ||
| 343 | nativeEventLogger: { | ||
| 344 | filePath: "memory://grok-native-events", | ||
| 345 | write: (record: unknown) => | ||
| 346 | typeof record === "object" && | ||
| 347 | record !== null && | ||
| 348 | "event" in record && | ||
| 349 | typeof record.event === "object" && | ||
| 350 | record.event !== null && | ||
| 351 | "kind" in record.event && | ||
| 352 | record.event.kind === "notification" | ||
| 353 | ? Effect.die(new Error("native log write failed")) | ||
| 354 | : Effect.void, | ||
| 355 | close: () => Effect.void, | ||
| 356 | }, | ||
| 357 | }); | ||
| 358 | const contentDelta = yield* Deferred.make<void>(); | ||
| 359 | const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 360 | event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void, | ||
| 361 | ).pipe(Effect.forkChild); | ||
| 362 | |||
| 363 | yield* adapter.startSession({ | ||
| 364 | threadId, | ||
| 365 | provider: ProviderDriverKind.make("grok"), | ||
| 366 | cwd: process.cwd(), | ||
| 367 | runtimeMode: "full-access", | ||
| 368 | }); | ||
| 369 | yield* adapter.sendTurn({ threadId, input: "keep streaming", attachments: [] }); | ||
| 370 | yield* Deferred.await(contentDelta); | ||
| 371 | |||
| 372 | yield* Fiber.interrupt(eventsFiber); | ||
| 373 | yield* adapter.stopSession(threadId); | ||
| 374 | }), | ||
| 375 | ); | ||
| 376 | }); | ||
apps/server/src/provider/Layers/GrokAdapter.ts created+934| ... | @@ -0,0 +1,934 @@ | ||
| 1 | import { | ||
| 2 | ApprovalRequestId, | ||
| 3 | type GrokSettings, | ||
| 4 | EventId, | ||
| 5 | type ProviderApprovalDecision, | ||
| 6 | type ProviderRuntimeEvent, | ||
| 7 | type ProviderSession, | ||
| 8 | type ProviderUserInputAnswers, | ||
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 11 | RuntimeRequestId, | ||
| 12 | type ThreadId, | ||
| 13 | TurnId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as Crypto from "effect/Crypto"; | ||
| 16 | import * as DateTime from "effect/DateTime"; | ||
| 17 | import * as Deferred from "effect/Deferred"; | ||
| 18 | import * as Effect from "effect/Effect"; | ||
| 19 | import * as Exit from "effect/Exit"; | ||
| 20 | import * as Fiber from "effect/Fiber"; | ||
| 21 | import * as FileSystem from "effect/FileSystem"; | ||
| 22 | import * as Option from "effect/Option"; | ||
| 23 | import * as Path from "effect/Path"; | ||
| 24 | import * as PubSub from "effect/PubSub"; | ||
| 25 | import * as Schema from "effect/Schema"; | ||
| 26 | import * as Scope from "effect/Scope"; | ||
| 27 | import * as Semaphore from "effect/Semaphore"; | ||
| 28 | import * as Stream from "effect/Stream"; | ||
| 29 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 30 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 31 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 32 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 33 | |||
| 34 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| 35 | import { ServerConfig } from "../../config.ts"; | ||
| 36 | import { | ||
| 37 | ProviderAdapterProcessError, | ||
| 38 | ProviderAdapterRequestError, | ||
| 39 | ProviderAdapterSessionNotFoundError, | ||
| 40 | ProviderAdapterValidationError, | ||
| 41 | } from "../Errors.ts"; | ||
| 42 | import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; | ||
| 43 | import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; | ||
| 44 | import { | ||
| 45 | makeAcpAssistantItemEvent, | ||
| 46 | makeAcpContentDeltaEvent, | ||
| 47 | makeAcpPlanUpdatedEvent, | ||
| 48 | makeAcpRequestOpenedEvent, | ||
| 49 | makeAcpRequestResolvedEvent, | ||
| 50 | makeAcpToolCallEvent, | ||
| 51 | } from "../acp/AcpCoreRuntimeEvents.ts"; | ||
| 52 | import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; | ||
| 53 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; | ||
| 54 | import { | ||
| 55 | applyGrokAcpModelSelection, | ||
| 56 | currentGrokModelIdFromSessionSetup, | ||
| 57 | makeGrokAcpRuntime, | ||
| 58 | resolveGrokAcpBaseModelId, | ||
| 59 | } from "../acp/GrokAcpSupport.ts"; | ||
| 60 | import { | ||
| 61 | extractXAiAskUserQuestions, | ||
| 62 | makeXAiAskUserQuestionCancelledResponse, | ||
| 63 | makeXAiAskUserQuestionResponse, | ||
| 64 | XAiAskUserQuestionRequest, | ||
| 65 | } from "../acp/XAiAcpExtension.ts"; | ||
| 66 | import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; | ||
| 67 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | ||
| 68 | |||
| 69 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); | ||
| 70 | |||
| 71 | const PROVIDER = ProviderDriverKind.make("grok"); | ||
| 72 | const GROK_RESUME_VERSION = 1 as const; | ||
| 73 | |||
| 74 | function encodeJsonStringForDiagnostics(input: unknown): string | undefined { | ||
| 75 | const result = encodeUnknownJsonStringExit(input); | ||
| 76 | return Exit.isSuccess(result) ? result.value : undefined; | ||
| 77 | } | ||
| 78 | |||
| 79 | export interface GrokAdapterLiveOptions { | ||
| 80 | readonly environment?: NodeJS.ProcessEnv; | ||
| 81 | readonly nativeEventLogPath?: string; | ||
| 82 | readonly nativeEventLogger?: EventNdjsonLogger; | ||
| 83 | readonly instanceId?: ProviderInstanceId; | ||
| 84 | } | ||
| 85 | |||
| 86 | interface PendingApproval { | ||
| 87 | readonly decision: Deferred.Deferred<ProviderApprovalDecision>; | ||
| 88 | } | ||
| 89 | |||
| 90 | type PendingUserInputResolution = | ||
| 91 | | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } | ||
| 92 | | { readonly _tag: "cancelled" }; | ||
| 93 | |||
| 94 | interface PendingUserInput { | ||
| 95 | readonly resolution: Deferred.Deferred<PendingUserInputResolution>; | ||
| 96 | } | ||
| 97 | |||
| 98 | interface GrokSessionContext { | ||
| 99 | readonly threadId: ThreadId; | ||
| 100 | readonly acpSessionId: string; | ||
| 101 | session: ProviderSession; | ||
| 102 | readonly scope: Scope.Closeable; | ||
| 103 | readonly acp: AcpSessionRuntimeShape; | ||
| 104 | notificationFiber: Fiber.Fiber<void, never> | undefined; | ||
| 105 | readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>; | ||
| 106 | readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>; | ||
| 107 | turns: Array<{ id: TurnId; items: Array<unknown> }>; | ||
| 108 | lastPlanFingerprint: string | undefined; | ||
| 109 | activeTurnId: TurnId | undefined; | ||
| 110 | currentModelId: string | undefined; | ||
| 111 | stopped: boolean; | ||
| 112 | } | ||
| 113 | |||
| 114 | function settlePendingApprovalsAsCancelled( | ||
| 115 | pendingApprovals: ReadonlyMap<ApprovalRequestId, PendingApproval>, | ||
| 116 | ): Effect.Effect<void> { | ||
| 117 | return Effect.forEach( | ||
| 118 | Array.from(pendingApprovals.values()), | ||
| 119 | (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), | ||
| 120 | { discard: true }, | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | |||
| 124 | function settlePendingUserInputsAsCancelled( | ||
| 125 | pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>, | ||
| 126 | ): Effect.Effect<void> { | ||
| 127 | return Effect.forEach( | ||
| 128 | Array.from(pendingUserInputs.values()), | ||
| 129 | (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), | ||
| 130 | { discard: true }, | ||
| 131 | ); | ||
| 132 | } | ||
| 133 | |||
| 134 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 135 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 136 | } | ||
| 137 | |||
| 138 | function parseGrokResume(raw: unknown): { sessionId: string } | undefined { | ||
| 139 | if (!isRecord(raw)) return undefined; | ||
| 140 | if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined; | ||
| 141 | if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; | ||
| 142 | return { sessionId: raw.sessionId.trim() }; | ||
| 143 | } | ||
| 144 | |||
| 145 | function selectPermissionOptionId( | ||
| 146 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 147 | decision: Exclude<ProviderApprovalDecision, "cancel">, | ||
| 148 | ): string | undefined { | ||
| 149 | const kind = | ||
| 150 | decision === "acceptForSession" | ||
| 151 | ? "allow_always" | ||
| 152 | : decision === "accept" | ||
| 153 | ? "allow_once" | ||
| 154 | : "reject_once"; | ||
| 155 | const option = request.options.find((entry) => entry.kind === kind); | ||
| 156 | return option?.optionId.trim() || undefined; | ||
| 157 | } | ||
| 158 | |||
| 159 | function selectAutoApprovedPermissionOption( | ||
| 160 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 161 | ): string | undefined { | ||
| 162 | return ( | ||
| 163 | selectPermissionOptionId(request, "acceptForSession") ?? | ||
| 164 | selectPermissionOptionId(request, "accept") | ||
| 165 | ); | ||
| 166 | } | ||
| 167 | |||
| 168 | export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { | ||
| 169 | return Effect.gen(function* () { | ||
| 170 | const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); | ||
| 171 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 172 | const path = yield* Path.Path; | ||
| 173 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 174 | const serverConfig = yield* Effect.service(ServerConfig); | ||
| 175 | const crypto = yield* Crypto.Crypto; | ||
| 176 | const nativeEventLogger = | ||
| 177 | options?.nativeEventLogger ?? | ||
| 178 | (options?.nativeEventLogPath !== undefined | ||
| 179 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) | ||
| 180 | : undefined); | ||
| 181 | const managedNativeEventLogger = | ||
| 182 | options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; | ||
| 183 | const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); | ||
| 184 | |||
| 185 | const sessions = new Map<ThreadId, GrokSessionContext>(); | ||
| 186 | const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>()); | ||
| 187 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | ||
| 188 | |||
| 189 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | ||
| 190 | const randomUUIDv4 = crypto.randomUUIDv4.pipe( | ||
| 191 | Effect.mapError( | ||
| 192 | (cause) => | ||
| 193 | new ProviderAdapterRequestError({ | ||
| 194 | provider: PROVIDER, | ||
| 195 | method: "crypto/randomUUIDv4", | ||
| 196 | detail: "Failed to generate Grok runtime identifier.", | ||
| 197 | cause, | ||
| 198 | }), | ||
| 199 | ), | ||
| 200 | ); | ||
| 201 | const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); | ||
| 202 | const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); | ||
| 203 | const mapAcpCallbackFailure = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 204 | effect.pipe( | ||
| 205 | Effect.mapError( | ||
| 206 | (cause) => | ||
| 207 | new EffectAcpErrors.AcpTransportError({ | ||
| 208 | detail: "Failed to process Grok ACP callback.", | ||
| 209 | cause, | ||
| 210 | }), | ||
| 211 | ), | ||
| 212 | ); | ||
| 213 | |||
| 214 | const offerRuntimeEvent = (event: ProviderRuntimeEvent) => | ||
| 215 | PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); | ||
| 216 | |||
| 217 | const getThreadSemaphore = (threadId: string) => | ||
| 218 | SynchronizedRef.modifyEffect(threadLocksRef, (current) => { | ||
| 219 | const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr( | ||
| 220 | current.get(threadId), | ||
| 221 | ); | ||
| 222 | return Option.match(existing, { | ||
| 223 | onNone: () => | ||
| 224 | Semaphore.make(1).pipe( | ||
| 225 | Effect.map((semaphore) => { | ||
| 226 | const next = new Map(current); | ||
| 227 | next.set(threadId, semaphore); | ||
| 228 | return [semaphore, next] as const; | ||
| 229 | }), | ||
| 230 | ), | ||
| 231 | onSome: (semaphore) => Effect.succeed([semaphore, current] as const), | ||
| 232 | }); | ||
| 233 | }); | ||
| 234 | |||
| 235 | const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) => | ||
| 236 | Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); | ||
| 237 | |||
| 238 | const logNative = (threadId: ThreadId, method: string, payload: unknown) => | ||
| 239 | Effect.gen(function* () { | ||
| 240 | if (!nativeEventLogger) return; | ||
| 241 | const observedAt = yield* nowIso; | ||
| 242 | yield* nativeEventLogger.write( | ||
| 243 | { | ||
| 244 | observedAt, | ||
| 245 | event: { | ||
| 246 | id: yield* randomUUIDv4, | ||
| 247 | kind: "notification", | ||
| 248 | provider: PROVIDER, | ||
| 249 | createdAt: observedAt, | ||
| 250 | method, | ||
| 251 | threadId, | ||
| 252 | payload, | ||
| 253 | }, | ||
| 254 | }, | ||
| 255 | threadId, | ||
| 256 | ); | ||
| 257 | }).pipe( | ||
| 258 | Effect.catchCause((cause) => | ||
| 259 | Effect.logWarning("Failed to write native Grok notification log.", { | ||
| 260 | cause, | ||
| 261 | threadId, | ||
| 262 | method, | ||
| 263 | }), | ||
| 264 | ), | ||
| 265 | ); | ||
| 266 | |||
| 267 | const emitPlanUpdate = ( | ||
| 268 | ctx: GrokSessionContext, | ||
| 269 | payload: { | ||
| 270 | readonly explanation?: string | null; | ||
| 271 | readonly plan: ReadonlyArray<{ | ||
| 272 | readonly step: string; | ||
| 273 | readonly status: "pending" | "inProgress" | "completed"; | ||
| 274 | }>; | ||
| 275 | }, | ||
| 276 | rawPayload: unknown, | ||
| 277 | method: string, | ||
| 278 | ) => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; | ||
| 281 | if (ctx.lastPlanFingerprint === fingerprint) { | ||
| 282 | return; | ||
| 283 | } | ||
| 284 | ctx.lastPlanFingerprint = fingerprint; | ||
| 285 | yield* offerRuntimeEvent( | ||
| 286 | makeAcpPlanUpdatedEvent({ | ||
| 287 | stamp: yield* makeEventStamp(), | ||
| 288 | provider: PROVIDER, | ||
| 289 | threadId: ctx.threadId, | ||
| 290 | turnId: ctx.activeTurnId, | ||
| 291 | payload, | ||
| 292 | source: "acp.jsonrpc", | ||
| 293 | method, | ||
| 294 | rawPayload, | ||
| 295 | }), | ||
| 296 | ); | ||
| 297 | }); | ||
| 298 | |||
| 299 | const requireSession = ( | ||
| 300 | threadId: ThreadId, | ||
| 301 | ): Effect.Effect<GrokSessionContext, ProviderAdapterSessionNotFoundError> => { | ||
| 302 | const ctx = sessions.get(threadId); | ||
| 303 | if (!ctx || ctx.stopped) { | ||
| 304 | return Effect.fail( | ||
| 305 | new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), | ||
| 306 | ); | ||
| 307 | } | ||
| 308 | return Effect.succeed(ctx); | ||
| 309 | }; | ||
| 310 | |||
| 311 | const stopSessionInternal = (ctx: GrokSessionContext) => | ||
| 312 | Effect.gen(function* () { | ||
| 313 | if (ctx.stopped) return; | ||
| 314 | ctx.stopped = true; | ||
| 315 | yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); | ||
| 316 | yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); | ||
| 317 | if (ctx.notificationFiber) { | ||
| 318 | yield* Fiber.interrupt(ctx.notificationFiber); | ||
| 319 | } | ||
| 320 | yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); | ||
| 321 | sessions.delete(ctx.threadId); | ||
| 322 | yield* offerRuntimeEvent({ | ||
| 323 | type: "session.exited", | ||
| 324 | ...(yield* makeEventStamp()), | ||
| 325 | provider: PROVIDER, | ||
| 326 | threadId: ctx.threadId, | ||
| 327 | payload: { exitKind: "graceful" }, | ||
| 328 | }); | ||
| 329 | }); | ||
| 330 | |||
| 331 | const startSession: GrokAdapterShape["startSession"] = (input) => | ||
| 332 | withThreadLock( | ||
| 333 | input.threadId, | ||
| 334 | Effect.gen(function* () { | ||
| 335 | if (input.provider !== undefined && input.provider !== PROVIDER) { | ||
| 336 | return yield* new ProviderAdapterValidationError({ | ||
| 337 | provider: PROVIDER, | ||
| 338 | operation: "startSession", | ||
| 339 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, | ||
| 340 | }); | ||
| 341 | } | ||
| 342 | if (!input.cwd?.trim()) { | ||
| 343 | return yield* new ProviderAdapterValidationError({ | ||
| 344 | provider: PROVIDER, | ||
| 345 | operation: "startSession", | ||
| 346 | issue: "cwd is required and must be non-empty.", | ||
| 347 | }); | ||
| 348 | } | ||
| 349 | |||
| 350 | const cwd = path.resolve(input.cwd.trim()); | ||
| 351 | const grokModelSelection = | ||
| 352 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; | ||
| 353 | const existing = sessions.get(input.threadId); | ||
| 354 | if (existing && !existing.stopped) { | ||
| 355 | yield* stopSessionInternal(existing); | ||
| 356 | } | ||
| 357 | |||
| 358 | const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>(); | ||
| 359 | const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>(); | ||
| 360 | const sessionScope = yield* Scope.make("sequential"); | ||
| 361 | let sessionScopeTransferred = false; | ||
| 362 | yield* Effect.addFinalizer(() => | ||
| 363 | sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), | ||
| 364 | ); | ||
| 365 | |||
| 366 | const resumeSessionId = parseGrokResume(input.resumeCursor)?.sessionId; | ||
| 367 | const acpNativeLoggers = makeAcpNativeLoggers({ | ||
| 368 | nativeEventLogger, | ||
| 369 | provider: PROVIDER, | ||
| 370 | threadId: input.threadId, | ||
| 371 | }); | ||
| 372 | |||
| 373 | const acp = yield* makeGrokAcpRuntime({ | ||
| 374 | grokSettings, | ||
| 375 | ...(options?.environment ? { environment: options.environment } : {}), | ||
| 376 | childProcessSpawner, | ||
| 377 | cwd, | ||
| 378 | ...(resumeSessionId ? { resumeSessionId } : {}), | ||
| 379 | clientInfo: { name: "t3-code", version: "0.0.0" }, | ||
| 380 | ...acpNativeLoggers, | ||
| 381 | }).pipe( | ||
| 382 | Effect.provideService(Scope.Scope, sessionScope), | ||
| 383 | Effect.mapError( | ||
| 384 | (cause) => | ||
| 385 | new ProviderAdapterProcessError({ | ||
| 386 | provider: PROVIDER, | ||
| 387 | threadId: input.threadId, | ||
| 388 | detail: cause.message, | ||
| 389 | cause, | ||
| 390 | }), | ||
| 391 | ), | ||
| 392 | ); | ||
| 393 | const started = yield* Effect.gen(function* () { | ||
| 394 | yield* Effect.forEach( | ||
| 395 | ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, | ||
| 396 | (method) => | ||
| 397 | acp.handleExtRequest(method, XAiAskUserQuestionRequest, (params) => | ||
| 398 | mapAcpCallbackFailure( | ||
| 399 | Effect.gen(function* () { | ||
| 400 | yield* logNative(input.threadId, method, params); | ||
| 401 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | ||
| 402 | const runtimeRequestId = RuntimeRequestId.make(requestId); | ||
| 403 | const resolution = yield* Deferred.make<PendingUserInputResolution>(); | ||
| 404 | pendingUserInputs.set(requestId, { resolution }); | ||
| 405 | yield* offerRuntimeEvent({ | ||
| 406 | type: "user-input.requested", | ||
| 407 | ...(yield* makeEventStamp()), | ||
| 408 | provider: PROVIDER, | ||
| 409 | threadId: input.threadId, | ||
| 410 | turnId: sessions.get(input.threadId)?.activeTurnId, | ||
| 411 | requestId: runtimeRequestId, | ||
| 412 | payload: { questions: extractXAiAskUserQuestions(params) }, | ||
| 413 | raw: { | ||
| 414 | source: "acp.grok.extension", | ||
| 415 | method, | ||
| 416 | payload: params, | ||
| 417 | }, | ||
| 418 | }); | ||
| 419 | const resolved = yield* Deferred.await(resolution); | ||
| 420 | pendingUserInputs.delete(requestId); | ||
| 421 | const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; | ||
| 422 | yield* offerRuntimeEvent({ | ||
| 423 | type: "user-input.resolved", | ||
| 424 | ...(yield* makeEventStamp()), | ||
| 425 | provider: PROVIDER, | ||
| 426 | threadId: input.threadId, | ||
| 427 | turnId: sessions.get(input.threadId)?.activeTurnId, | ||
| 428 | requestId: runtimeRequestId, | ||
| 429 | payload: { answers: resolvedAnswers }, | ||
| 430 | raw: { | ||
| 431 | source: "acp.grok.extension", | ||
| 432 | method, | ||
| 433 | payload: params, | ||
| 434 | }, | ||
| 435 | }); | ||
| 436 | switch (resolved._tag) { | ||
| 437 | case "answered": | ||
| 438 | return makeXAiAskUserQuestionResponse(params, resolved.answers); | ||
| 439 | case "cancelled": | ||
| 440 | return makeXAiAskUserQuestionCancelledResponse(); | ||
| 441 | } | ||
| 442 | }), | ||
| 443 | ), | ||
| 444 | ), | ||
| 445 | { discard: true }, | ||
| 446 | ); | ||
| 447 | yield* acp.handleRequestPermission((params) => | ||
| 448 | mapAcpCallbackFailure( | ||
| 449 | Effect.gen(function* () { | ||
| 450 | yield* logNative(input.threadId, "session/request_permission", params); | ||
| 451 | if (input.runtimeMode === "full-access") { | ||
| 452 | const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); | ||
| 453 | if (autoApprovedOptionId !== undefined) { | ||
| 454 | return { | ||
| 455 | outcome: { | ||
| 456 | outcome: "selected" as const, | ||
| 457 | optionId: autoApprovedOptionId, | ||
| 458 | }, | ||
| 459 | }; | ||
| 460 | } | ||
| 461 | } | ||
| 462 | const permissionRequest = parsePermissionRequest(params); | ||
| 463 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | ||
| 464 | const runtimeRequestId = RuntimeRequestId.make(requestId); | ||
| 465 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | ||
| 466 | pendingApprovals.set(requestId, { decision }); | ||
| 467 | yield* offerRuntimeEvent( | ||
| 468 | makeAcpRequestOpenedEvent({ | ||
| 469 | stamp: yield* makeEventStamp(), | ||
| 470 | provider: PROVIDER, | ||
| 471 | threadId: input.threadId, | ||
| 472 | turnId: sessions.get(input.threadId)?.activeTurnId, | ||
| 473 | requestId: runtimeRequestId, | ||
| 474 | permissionRequest, | ||
| 475 | detail: | ||
| 476 | permissionRequest.detail ?? | ||
| 477 | encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? | ||
| 478 | "[unserializable params]", | ||
| 479 | args: params, | ||
| 480 | source: "acp.jsonrpc", | ||
| 481 | method: "session/request_permission", | ||
| 482 | rawPayload: params, | ||
| 483 | }), | ||
| 484 | ); | ||
| 485 | const resolved = yield* Deferred.await(decision); | ||
| 486 | pendingApprovals.delete(requestId); | ||
| 487 | yield* offerRuntimeEvent( | ||
| 488 | makeAcpRequestResolvedEvent({ | ||
| 489 | stamp: yield* makeEventStamp(), | ||
| 490 | provider: PROVIDER, | ||
| 491 | threadId: input.threadId, | ||
| 492 | turnId: sessions.get(input.threadId)?.activeTurnId, | ||
| 493 | requestId: runtimeRequestId, | ||
| 494 | permissionRequest, | ||
| 495 | decision: resolved, | ||
| 496 | }), | ||
| 497 | ); | ||
| 498 | const selectedOptionId = | ||
| 499 | resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); | ||
| 500 | return { | ||
| 501 | outcome: selectedOptionId | ||
| 502 | ? { | ||
| 503 | outcome: "selected" as const, | ||
| 504 | optionId: selectedOptionId, | ||
| 505 | } | ||
| 506 | : ({ outcome: "cancelled" } as const), | ||
| 507 | }; | ||
| 508 | }), | ||
| 509 | ), | ||
| 510 | ); | ||
| 511 | return yield* acp.start(); | ||
| 512 | }).pipe( | ||
| 513 | Effect.mapError((error) => | ||
| 514 | mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), | ||
| 515 | ), | ||
| 516 | ); | ||
| 517 | |||
| 518 | const requestedStartModelId = grokModelSelection?.model | ||
| 519 | ? resolveGrokAcpBaseModelId(grokModelSelection.model) | ||
| 520 | : undefined; | ||
| 521 | const boundModelId = yield* applyGrokAcpModelSelection({ | ||
| 522 | runtime: acp, | ||
| 523 | currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), | ||
| 524 | requestedModelId: requestedStartModelId, | ||
| 525 | mapError: (cause) => | ||
| 526 | mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), | ||
| 527 | }); | ||
| 528 | |||
| 529 | const now = yield* nowIso; | ||
| 530 | const session: ProviderSession = { | ||
| 531 | provider: PROVIDER, | ||
| 532 | providerInstanceId: boundInstanceId, | ||
| 533 | status: "ready", | ||
| 534 | runtimeMode: input.runtimeMode, | ||
| 535 | cwd, | ||
| 536 | ...(boundModelId ? { model: resolveGrokAcpBaseModelId(boundModelId) } : {}), | ||
| 537 | threadId: input.threadId, | ||
| 538 | resumeCursor: { | ||
| 539 | schemaVersion: GROK_RESUME_VERSION, | ||
| 540 | sessionId: started.sessionId, | ||
| 541 | }, | ||
| 542 | createdAt: now, | ||
| 543 | updatedAt: now, | ||
| 544 | }; | ||
| 545 | |||
| 546 | const ctx: GrokSessionContext = { | ||
| 547 | threadId: input.threadId, | ||
| 548 | acpSessionId: started.sessionId, | ||
| 549 | session, | ||
| 550 | scope: sessionScope, | ||
| 551 | acp, | ||
| 552 | notificationFiber: undefined, | ||
| 553 | pendingApprovals, | ||
| 554 | pendingUserInputs, | ||
| 555 | turns: [], | ||
| 556 | lastPlanFingerprint: undefined, | ||
| 557 | activeTurnId: undefined, | ||
| 558 | currentModelId: boundModelId, | ||
| 559 | stopped: false, | ||
| 560 | }; | ||
| 561 | |||
| 562 | const nf = yield* Stream.runDrain( | ||
| 563 | Stream.mapEffect(acp.getEvents(), (event) => | ||
| 564 | Effect.gen(function* () { | ||
| 565 | switch (event._tag) { | ||
| 566 | case "AssistantItemStarted": | ||
| 567 | yield* offerRuntimeEvent( | ||
| 568 | makeAcpAssistantItemEvent({ | ||
| 569 | stamp: yield* makeEventStamp(), | ||
| 570 | provider: PROVIDER, | ||
| 571 | threadId: ctx.threadId, | ||
| 572 | turnId: ctx.activeTurnId, | ||
| 573 | itemId: event.itemId, | ||
| 574 | lifecycle: "item.started", | ||
| 575 | }), | ||
| 576 | ); | ||
| 577 | return; | ||
| 578 | case "AssistantItemCompleted": | ||
| 579 | yield* offerRuntimeEvent( | ||
| 580 | makeAcpAssistantItemEvent({ | ||
| 581 | stamp: yield* makeEventStamp(), | ||
| 582 | provider: PROVIDER, | ||
| 583 | threadId: ctx.threadId, | ||
| 584 | turnId: ctx.activeTurnId, | ||
| 585 | itemId: event.itemId, | ||
| 586 | lifecycle: "item.completed", | ||
| 587 | }), | ||
| 588 | ); | ||
| 589 | return; | ||
| 590 | case "PlanUpdated": | ||
| 591 | yield* logNative(ctx.threadId, "session/update", event.rawPayload); | ||
| 592 | yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update"); | ||
| 593 | return; | ||
| 594 | case "ToolCallUpdated": | ||
| 595 | yield* logNative(ctx.threadId, "session/update", event.rawPayload); | ||
| 596 | yield* offerRuntimeEvent( | ||
| 597 | makeAcpToolCallEvent({ | ||
| 598 | stamp: yield* makeEventStamp(), | ||
| 599 | provider: PROVIDER, | ||
| 600 | threadId: ctx.threadId, | ||
| 601 | turnId: ctx.activeTurnId, | ||
| 602 | toolCall: event.toolCall, | ||
| 603 | rawPayload: event.rawPayload, | ||
| 604 | }), | ||
| 605 | ); | ||
| 606 | return; | ||
| 607 | case "ContentDelta": | ||
| 608 | yield* logNative(ctx.threadId, "session/update", event.rawPayload); | ||
| 609 | yield* offerRuntimeEvent( | ||
| 610 | makeAcpContentDeltaEvent({ | ||
| 611 | stamp: yield* makeEventStamp(), | ||
| 612 | provider: PROVIDER, | ||
| 613 | threadId: ctx.threadId, | ||
| 614 | turnId: ctx.activeTurnId, | ||
| 615 | ...(event.itemId ? { itemId: event.itemId } : {}), | ||
| 616 | text: event.text, | ||
| 617 | rawPayload: event.rawPayload, | ||
| 618 | }), | ||
| 619 | ); | ||
| 620 | return; | ||
| 621 | } | ||
| 622 | }), | ||
| 623 | ), | ||
| 624 | ).pipe( | ||
| 625 | Effect.catch((cause) => | ||
| 626 | Effect.logError("Failed to process Grok runtime notification.", { cause }), | ||
| 627 | ), | ||
| 628 | Effect.forkChild, | ||
| 629 | ); | ||
| 630 | |||
| 631 | ctx.notificationFiber = nf; | ||
| 632 | sessions.set(input.threadId, ctx); | ||
| 633 | sessionScopeTransferred = true; | ||
| 634 | |||
| 635 | yield* offerRuntimeEvent({ | ||
| 636 | type: "session.started", | ||
| 637 | ...(yield* makeEventStamp()), | ||
| 638 | provider: PROVIDER, | ||
| 639 | threadId: input.threadId, | ||
| 640 | payload: { resume: started.initializeResult }, | ||
| 641 | }); | ||
| 642 | yield* offerRuntimeEvent({ | ||
| 643 | type: "session.state.changed", | ||
| 644 | ...(yield* makeEventStamp()), | ||
| 645 | provider: PROVIDER, | ||
| 646 | threadId: input.threadId, | ||
| 647 | payload: { state: "ready", reason: "Grok ACP session ready" }, | ||
| 648 | }); | ||
| 649 | yield* offerRuntimeEvent({ | ||
| 650 | type: "thread.started", | ||
| 651 | ...(yield* makeEventStamp()), | ||
| 652 | provider: PROVIDER, | ||
| 653 | threadId: input.threadId, | ||
| 654 | payload: { providerThreadId: started.sessionId }, | ||
| 655 | }); | ||
| 656 | |||
| 657 | return session; | ||
| 658 | }).pipe(Effect.scoped), | ||
| 659 | ); | ||
| 660 | |||
| 661 | const sendTurn: GrokAdapterShape["sendTurn"] = (input) => | ||
| 662 | Effect.gen(function* () { | ||
| 663 | const prepared = yield* withThreadLock( | ||
| 664 | input.threadId, | ||
| 665 | Effect.gen(function* () { | ||
| 666 | const ctx = yield* requireSession(input.threadId); | ||
| 667 | const turnId = TurnId.make(yield* randomUUIDv4); | ||
| 668 | const turnModelSelection = | ||
| 669 | input.modelSelection?.instanceId === boundInstanceId | ||
| 670 | ? input.modelSelection | ||
| 671 | : undefined; | ||
| 672 | const requestedTurnModelId = turnModelSelection?.model | ||
| 673 | ? resolveGrokAcpBaseModelId(turnModelSelection.model) | ||
| 674 | : undefined; | ||
| 675 | const currentModelId = yield* applyGrokAcpModelSelection({ | ||
| 676 | runtime: ctx.acp, | ||
| 677 | currentModelId: ctx.currentModelId, | ||
| 678 | requestedModelId: requestedTurnModelId, | ||
| 679 | mapError: (cause) => | ||
| 680 | mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), | ||
| 681 | }); | ||
| 682 | |||
| 683 | const text = input.input?.trim(); | ||
| 684 | const imagePromptParts = yield* Effect.forEach(input.attachments ?? [], (attachment) => | ||
| 685 | Effect.gen(function* () { | ||
| 686 | const attachmentPath = resolveAttachmentPath({ | ||
| 687 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 688 | attachment, | ||
| 689 | }); | ||
| 690 | if (!attachmentPath) { | ||
| 691 | return yield* new ProviderAdapterRequestError({ | ||
| 692 | provider: PROVIDER, | ||
| 693 | method: "session/prompt", | ||
| 694 | detail: `Invalid attachment id '${attachment.id}'.`, | ||
| 695 | }); | ||
| 696 | } | ||
| 697 | const bytes = yield* fileSystem.readFile(attachmentPath).pipe( | ||
| 698 | Effect.mapError( | ||
| 699 | (cause) => | ||
| 700 | new ProviderAdapterRequestError({ | ||
| 701 | provider: PROVIDER, | ||
| 702 | method: "session/prompt", | ||
| 703 | detail: cause.message, | ||
| 704 | cause, | ||
| 705 | }), | ||
| 706 | ), | ||
| 707 | ); | ||
| 708 | return { | ||
| 709 | type: "image", | ||
| 710 | data: Buffer.from(bytes).toString("base64"), | ||
| 711 | mimeType: attachment.mimeType, | ||
| 712 | } satisfies EffectAcpSchema.ContentBlock; | ||
| 713 | }), | ||
| 714 | ); | ||
| 715 | const promptParts: Array<EffectAcpSchema.ContentBlock> = [ | ||
| 716 | ...(text ? [{ type: "text" as const, text }] : []), | ||
| 717 | ...imagePromptParts, | ||
| 718 | ]; | ||
| 719 | |||
| 720 | if (promptParts.length === 0) { | ||
| 721 | return yield* new ProviderAdapterValidationError({ | ||
| 722 | provider: PROVIDER, | ||
| 723 | operation: "sendTurn", | ||
| 724 | issue: "Turn requires non-empty text or attachments.", | ||
| 725 | }); | ||
| 726 | } | ||
| 727 | |||
| 728 | ctx.currentModelId = currentModelId; | ||
| 729 | const displayModel = currentModelId | ||
| 730 | ? resolveGrokAcpBaseModelId(currentModelId) | ||
| 731 | : undefined; | ||
| 732 | ctx.activeTurnId = turnId; | ||
| 733 | ctx.lastPlanFingerprint = undefined; | ||
| 734 | ctx.session = { | ||
| 735 | ...ctx.session, | ||
| 736 | activeTurnId: turnId, | ||
| 737 | updatedAt: yield* nowIso, | ||
| 738 | ...(displayModel ? { model: displayModel } : {}), | ||
| 739 | }; | ||
| 740 | |||
| 741 | yield* offerRuntimeEvent({ | ||
| 742 | type: "turn.started", | ||
| 743 | ...(yield* makeEventStamp()), | ||
| 744 | provider: PROVIDER, | ||
| 745 | threadId: input.threadId, | ||
| 746 | turnId, | ||
| 747 | payload: displayModel ? { model: displayModel } : {}, | ||
| 748 | }); | ||
| 749 | |||
| 750 | return { | ||
| 751 | acp: ctx.acp, | ||
| 752 | acpSessionId: ctx.acpSessionId, | ||
| 753 | displayModel, | ||
| 754 | promptParts, | ||
| 755 | turnId, | ||
| 756 | }; | ||
| 757 | }), | ||
| 758 | ); | ||
| 759 | |||
| 760 | const result = yield* prepared.acp | ||
| 761 | .prompt({ | ||
| 762 | prompt: prepared.promptParts, | ||
| 763 | }) | ||
| 764 | .pipe( | ||
| 765 | Effect.mapError((error) => | ||
| 766 | mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), | ||
| 767 | ), | ||
| 768 | ); | ||
| 769 | |||
| 770 | return yield* withThreadLock( | ||
| 771 | input.threadId, | ||
| 772 | Effect.gen(function* () { | ||
| 773 | const ctx = yield* requireSession(input.threadId); | ||
| 774 | if (ctx.acpSessionId !== prepared.acpSessionId) { | ||
| 775 | return yield* new ProviderAdapterRequestError({ | ||
| 776 | provider: PROVIDER, | ||
| 777 | method: "session/prompt", | ||
| 778 | detail: "Grok session changed before the turn completed.", | ||
| 779 | }); | ||
| 780 | } | ||
| 781 | |||
| 782 | ctx.turns = [ | ||
| 783 | ...ctx.turns, | ||
| 784 | { id: prepared.turnId, items: [{ prompt: prepared.promptParts, result }] }, | ||
| 785 | ]; | ||
| 786 | ctx.session = { | ||
| 787 | ...ctx.session, | ||
| 788 | activeTurnId: prepared.turnId, | ||
| 789 | updatedAt: yield* nowIso, | ||
| 790 | ...(prepared.displayModel ? { model: prepared.displayModel } : {}), | ||
| 791 | }; | ||
| 792 | |||
| 793 | yield* offerRuntimeEvent({ | ||
| 794 | type: "turn.completed", | ||
| 795 | ...(yield* makeEventStamp()), | ||
| 796 | provider: PROVIDER, | ||
| 797 | threadId: input.threadId, | ||
| 798 | turnId: prepared.turnId, | ||
| 799 | payload: { | ||
| 800 | state: result.stopReason === "cancelled" ? "cancelled" : "completed", | ||
| 801 | stopReason: result.stopReason ?? null, | ||
| 802 | }, | ||
| 803 | }); | ||
| 804 | |||
| 805 | return { | ||
| 806 | threadId: input.threadId, | ||
| 807 | turnId: prepared.turnId, | ||
| 808 | resumeCursor: ctx.session.resumeCursor, | ||
| 809 | }; | ||
| 810 | }), | ||
| 811 | ); | ||
| 812 | }); | ||
| 813 | |||
| 814 | const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId) => | ||
| 815 | Effect.gen(function* () { | ||
| 816 | const ctx = yield* requireSession(threadId); | ||
| 817 | yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); | ||
| 818 | yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); | ||
| 819 | yield* Effect.ignore( | ||
| 820 | ctx.acp.cancel.pipe( | ||
| 821 | Effect.mapError((error) => | ||
| 822 | mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), | ||
| 823 | ), | ||
| 824 | ), | ||
| 825 | ); | ||
| 826 | }); | ||
| 827 | |||
| 828 | const respondToRequest: GrokAdapterShape["respondToRequest"] = ( | ||
| 829 | threadId, | ||
| 830 | requestId, | ||
| 831 | decision, | ||
| 832 | ) => | ||
| 833 | Effect.gen(function* () { | ||
| 834 | const ctx = yield* requireSession(threadId); | ||
| 835 | const pending = ctx.pendingApprovals.get(requestId); | ||
| 836 | if (!pending) { | ||
| 837 | return yield* new ProviderAdapterRequestError({ | ||
| 838 | provider: PROVIDER, | ||
| 839 | method: "session/request_permission", | ||
| 840 | detail: `Unknown pending approval request: ${requestId}`, | ||
| 841 | }); | ||
| 842 | } | ||
| 843 | yield* Deferred.succeed(pending.decision, decision); | ||
| 844 | }); | ||
| 845 | |||
| 846 | const respondToUserInput: GrokAdapterShape["respondToUserInput"] = ( | ||
| 847 | threadId, | ||
| 848 | requestId, | ||
| 849 | answers, | ||
| 850 | ) => | ||
| 851 | Effect.gen(function* () { | ||
| 852 | const ctx = yield* requireSession(threadId); | ||
| 853 | const pending = ctx.pendingUserInputs.get(requestId); | ||
| 854 | if (!pending) { | ||
| 855 | return yield* new ProviderAdapterRequestError({ | ||
| 856 | provider: PROVIDER, | ||
| 857 | method: "_x.ai/ask_user_question", | ||
| 858 | detail: `Unknown pending user-input request: ${requestId}`, | ||
| 859 | }); | ||
| 860 | } | ||
| 861 | yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); | ||
| 862 | }); | ||
| 863 | |||
| 864 | const readThread: GrokAdapterShape["readThread"] = (threadId) => | ||
| 865 | Effect.gen(function* () { | ||
| 866 | const ctx = yield* requireSession(threadId); | ||
| 867 | return { threadId, turns: ctx.turns }; | ||
| 868 | }); | ||
| 869 | |||
| 870 | const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) => | ||
| 871 | Effect.gen(function* () { | ||
| 872 | yield* requireSession(threadId); | ||
| 873 | if (!Number.isInteger(numTurns) || numTurns < 1) { | ||
| 874 | return yield* new ProviderAdapterValidationError({ | ||
| 875 | provider: PROVIDER, | ||
| 876 | operation: "rollbackThread", | ||
| 877 | issue: "numTurns must be an integer >= 1.", | ||
| 878 | }); | ||
| 879 | } | ||
| 880 | return yield* new ProviderAdapterRequestError({ | ||
| 881 | provider: PROVIDER, | ||
| 882 | method: "thread/rollback", | ||
| 883 | detail: "Grok ACP sessions do not support provider-side rollback yet.", | ||
| 884 | }); | ||
| 885 | }); | ||
| 886 | |||
| 887 | const stopSession: GrokAdapterShape["stopSession"] = (threadId) => | ||
| 888 | withThreadLock( | ||
| 889 | threadId, | ||
| 890 | Effect.gen(function* () { | ||
| 891 | const ctx = yield* requireSession(threadId); | ||
| 892 | yield* stopSessionInternal(ctx); | ||
| 893 | }), | ||
| 894 | ); | ||
| 895 | |||
| 896 | const listSessions: GrokAdapterShape["listSessions"] = () => | ||
| 897 | Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); | ||
| 898 | |||
| 899 | const hasSession: GrokAdapterShape["hasSession"] = (threadId) => | ||
| 900 | Effect.sync(() => { | ||
| 901 | const c = sessions.get(threadId); | ||
| 902 | return c !== undefined && !c.stopped; | ||
| 903 | }); | ||
| 904 | |||
| 905 | const stopAll: GrokAdapterShape["stopAll"] = () => | ||
| 906 | Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); | ||
| 907 | |||
| 908 | yield* Effect.addFinalizer(() => | ||
| 909 | Effect.ignore(stopAll()).pipe( | ||
| 910 | Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), | ||
| 911 | Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), | ||
| 912 | ), | ||
| 913 | ); | ||
| 914 | |||
| 915 | const streamEvents = Stream.fromPubSub(runtimeEventPubSub); | ||
| 916 | |||
| 917 | return { | ||
| 918 | provider: PROVIDER, | ||
| 919 | capabilities: { sessionModelSwitch: "in-session" }, | ||
| 920 | startSession, | ||
| 921 | sendTurn, | ||
| 922 | interruptTurn, | ||
| 923 | readThread, | ||
| 924 | rollbackThread, | ||
| 925 | respondToRequest, | ||
| 926 | respondToUserInput, | ||
| 927 | stopSession, | ||
| 928 | listSessions, | ||
| 929 | hasSession, | ||
| 930 | stopAll, | ||
| 931 | streamEvents, | ||
| 932 | } satisfies GrokAdapterShape; | ||
| 933 | }); | ||
| 934 | } | ||
apps/server/src/provider/Layers/GrokProvider.test.ts created+115| ... | @@ -0,0 +1,115 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Path from "effect/Path"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | ||
| 7 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 8 | import { GrokSettings } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; | ||
| 11 | |||
| 12 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | ||
| 13 | |||
| 14 | const runNode = <A, E>( | ||
| 15 | effect: Effect.Effect< | ||
| 16 | A, | ||
| 17 | E, | ||
| 18 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | ||
| 19 | >, | ||
| 20 | ): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); | ||
| 21 | |||
| 22 | describe("buildInitialGrokProviderSnapshot", () => { | ||
| 23 | it("returns a disabled snapshot when settings.enabled is false", async () => { | ||
| 24 | const snapshot = await Effect.runPromise( | ||
| 25 | buildInitialGrokProviderSnapshot(decodeGrokSettings({ enabled: false })), | ||
| 26 | ); | ||
| 27 | expect(snapshot.enabled).toBe(false); | ||
| 28 | expect(snapshot.status).toBe("disabled"); | ||
| 29 | expect(snapshot.installed).toBe(false); | ||
| 30 | expect(snapshot.message).toContain("disabled"); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("returns a pending snapshot by default", async () => { | ||
| 34 | const snapshot = await Effect.runPromise( | ||
| 35 | buildInitialGrokProviderSnapshot(decodeGrokSettings({})), | ||
| 36 | ); | ||
| 37 | expect(snapshot.enabled).toBe(true); | ||
| 38 | expect(snapshot.installed).toBe(true); | ||
| 39 | expect(snapshot.status).toBe("warning"); | ||
| 40 | expect(snapshot.version).toBeNull(); | ||
| 41 | expect(snapshot.message).toContain("Checking Grok"); | ||
| 42 | expect(snapshot.requiresNewThreadForModelChange).toBe(true); | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | |||
| 46 | describe("checkGrokProviderStatus", () => { | ||
| 47 | it("reports the binary as missing when the binary path does not resolve", async () => { | ||
| 48 | const snapshot = await runNode( | ||
| 49 | checkGrokProviderStatus( | ||
| 50 | decodeGrokSettings({ | ||
| 51 | enabled: true, | ||
| 52 | binaryPath: "/definitely/not/installed/grok-binary", | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | ); | ||
| 56 | expect(snapshot.enabled).toBe(true); | ||
| 57 | expect(snapshot.installed).toBe(false); | ||
| 58 | expect(snapshot.status).toBe("error"); | ||
| 59 | expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("reports an installed CLI as unhealthy when --version exits non-zero", async () => { | ||
| 63 | const snapshot = await runNode( | ||
| 64 | Effect.scoped( | ||
| 65 | Effect.gen(function* () { | ||
| 66 | const fs = yield* FileSystem.FileSystem; | ||
| 67 | const path = yield* Path.Path; | ||
| 68 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); | ||
| 69 | const grokPath = path.join(dir, "grok"); | ||
| 70 | yield* fs.writeFileString( | ||
| 71 | grokPath, | ||
| 72 | ["#!/bin/sh", 'printf "%s\\n" "broken grok install" >&2', "exit 2", ""].join("\n"), | ||
| 73 | ); | ||
| 74 | yield* fs.chmod(grokPath, 0o755); | ||
| 75 | |||
| 76 | return yield* checkGrokProviderStatus( | ||
| 77 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 78 | ); | ||
| 79 | }), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | |||
| 83 | expect(snapshot.enabled).toBe(true); | ||
| 84 | expect(snapshot.installed).toBe(true); | ||
| 85 | expect(snapshot.status).toBe("error"); | ||
| 86 | expect(snapshot.message).toContain("broken grok install"); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("reports an error when ACP model discovery is unavailable", async () => { | ||
| 90 | const snapshot = await runNode( | ||
| 91 | Effect.scoped( | ||
| 92 | Effect.gen(function* () { | ||
| 93 | const fs = yield* FileSystem.FileSystem; | ||
| 94 | const path = yield* Path.Path; | ||
| 95 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" }); | ||
| 96 | const grokPath = path.join(dir, "grok"); | ||
| 97 | yield* fs.writeFileString( | ||
| 98 | grokPath, | ||
| 99 | ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), | ||
| 100 | ); | ||
| 101 | yield* fs.chmod(grokPath, 0o755); | ||
| 102 | |||
| 103 | return yield* checkGrokProviderStatus( | ||
| 104 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 105 | ); | ||
| 106 | }), | ||
| 107 | ), | ||
| 108 | ); | ||
| 109 | |||
| 110 | expect(snapshot.status).toBe("error"); | ||
| 111 | expect(snapshot.installed).toBe(true); | ||
| 112 | expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); | ||
| 113 | expect(snapshot.message).toContain("ACP startup failed"); | ||
| 114 | }); | ||
| 115 | }); | ||
apps/server/src/provider/Layers/GrokProvider.ts created+324| ... | @@ -0,0 +1,324 @@ | ||
| 1 | import { | ||
| 2 | type GrokSettings, | ||
| 3 | type ModelCapabilities, | ||
| 4 | ProviderDriverKind, | ||
| 5 | type ServerProvider, | ||
| 6 | type ServerProviderModel, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 9 | import * as Cause from "effect/Cause"; | ||
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Exit from "effect/Exit"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Result from "effect/Result"; | ||
| 15 | import { HttpClient } from "effect/unstable/http"; | ||
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 17 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | buildServerProvider, | ||
| 21 | detailFromResult, | ||
| 22 | isCommandMissingCause, | ||
| 23 | parseGenericCliVersion, | ||
| 24 | providerModelsFromSettings, | ||
| 25 | spawnAndCollect, | ||
| 26 | type ServerProviderDraft, | ||
| 27 | } from "../providerSnapshot.ts"; | ||
| 28 | import { | ||
| 29 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 30 | type ProviderMaintenanceCapabilities, | ||
| 31 | } from "../providerMaintenance.ts"; | ||
| 32 | import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; | ||
| 33 | |||
| 34 | const GROK_PRESENTATION = { | ||
| 35 | displayName: "Grok", | ||
| 36 | badgeLabel: "Early Access", | ||
| 37 | showInteractionModeToggle: false, | ||
| 38 | requiresNewThreadForModelChange: true, | ||
| 39 | } as const; | ||
| 40 | const PROVIDER = ProviderDriverKind.make("grok"); | ||
| 41 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 42 | optionDescriptors: [], | ||
| 43 | }); | ||
| 44 | |||
| 45 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | ||
| 46 | const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; | ||
| 47 | |||
| 48 | const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | ||
| 49 | { | ||
| 50 | slug: "grok-build", | ||
| 51 | name: "Grok Build", | ||
| 52 | isCustom: false, | ||
| 53 | capabilities: EMPTY_CAPABILITIES, | ||
| 54 | }, | ||
| 55 | ]; | ||
| 56 | |||
| 57 | export function buildInitialGrokProviderSnapshot( | ||
| 58 | grokSettings: GrokSettings, | ||
| 59 | ): Effect.Effect<ServerProviderDraft> { | ||
| 60 | return Effect.gen(function* () { | ||
| 61 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 62 | const models = grokModelsFromSettings(grokSettings.customModels); | ||
| 63 | |||
| 64 | if (!grokSettings.enabled) { | ||
| 65 | return buildServerProvider({ | ||
| 66 | presentation: GROK_PRESENTATION, | ||
| 67 | enabled: false, | ||
| 68 | checkedAt, | ||
| 69 | models, | ||
| 70 | probe: { | ||
| 71 | installed: false, | ||
| 72 | version: null, | ||
| 73 | status: "warning", | ||
| 74 | auth: { status: "unknown" }, | ||
| 75 | message: "Grok is disabled in T3 Code settings.", | ||
| 76 | }, | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | |||
| 80 | return buildServerProvider({ | ||
| 81 | presentation: GROK_PRESENTATION, | ||
| 82 | enabled: true, | ||
| 83 | checkedAt, | ||
| 84 | models, | ||
| 85 | probe: { | ||
| 86 | installed: true, | ||
| 87 | version: null, | ||
| 88 | status: "warning", | ||
| 89 | auth: { status: "unknown" }, | ||
| 90 | message: "Checking Grok CLI availability...", | ||
| 91 | }, | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | } | ||
| 95 | |||
| 96 | function grokModelsFromSettings( | ||
| 97 | customModels: ReadonlyArray<string> | undefined, | ||
| 98 | builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS, | ||
| 99 | ): ReadonlyArray<ServerProviderModel> { | ||
| 100 | return providerModelsFromSettings( | ||
| 101 | builtInModels, | ||
| 102 | PROVIDER, | ||
| 103 | customModels ?? [], | ||
| 104 | EMPTY_CAPABILITIES, | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | function buildGrokDiscoveredModelsFromSessionModelState( | ||
| 109 | modelState: EffectAcpSchema.SessionModelState | null | undefined, | ||
| 110 | ): ReadonlyArray<ServerProviderModel> { | ||
| 111 | if (!modelState || modelState.availableModels.length === 0) { | ||
| 112 | return []; | ||
| 113 | } | ||
| 114 | const seen = new Set<string>(); | ||
| 115 | return modelState.availableModels | ||
| 116 | .map((model): ServerProviderModel | undefined => { | ||
| 117 | const slug = resolveGrokAcpBaseModelId(model.modelId); | ||
| 118 | if (!slug || seen.has(slug)) { | ||
| 119 | return undefined; | ||
| 120 | } | ||
| 121 | seen.add(slug); | ||
| 122 | return { | ||
| 123 | slug, | ||
| 124 | name: model.name.trim() || slug, | ||
| 125 | isCustom: false, | ||
| 126 | capabilities: EMPTY_CAPABILITIES, | ||
| 127 | }; | ||
| 128 | }) | ||
| 129 | .filter((model): model is ServerProviderModel => model !== undefined); | ||
| 130 | } | ||
| 131 | |||
| 132 | const discoverGrokModelsViaAcp = ( | ||
| 133 | grokSettings: GrokSettings, | ||
| 134 | environment: NodeJS.ProcessEnv = process.env, | ||
| 135 | ) => | ||
| 136 | Effect.gen(function* () { | ||
| 137 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 138 | const acp = yield* makeGrokAcpRuntime({ | ||
| 139 | grokSettings, | ||
| 140 | environment, | ||
| 141 | childProcessSpawner, | ||
| 142 | cwd: process.cwd(), | ||
| 143 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, | ||
| 144 | }); | ||
| 145 | const started = yield* acp.start(); | ||
| 146 | return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); | ||
| 147 | }).pipe(Effect.scoped); | ||
| 148 | |||
| 149 | const runGrokVersionCommand = ( | ||
| 150 | grokSettings: GrokSettings, | ||
| 151 | environment: NodeJS.ProcessEnv = process.env, | ||
| 152 | ) => { | ||
| 153 | const command = grokSettings.binaryPath || "grok"; | ||
| 154 | return spawnAndCollect( | ||
| 155 | command, | ||
| 156 | ChildProcess.make(command, ["--version"], { | ||
| 157 | env: environment, | ||
| 158 | shell: process.platform === "win32", | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | }; | ||
| 162 | |||
| 163 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( | ||
| 164 | grokSettings: GrokSettings, | ||
| 165 | environment: NodeJS.ProcessEnv = process.env, | ||
| 166 | ): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> { | ||
| 167 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 168 | const fallbackModels = grokModelsFromSettings(grokSettings.customModels); | ||
| 169 | |||
| 170 | if (!grokSettings.enabled) { | ||
| 171 | return buildServerProvider({ | ||
| 172 | presentation: GROK_PRESENTATION, | ||
| 173 | enabled: false, | ||
| 174 | checkedAt, | ||
| 175 | models: fallbackModels, | ||
| 176 | probe: { | ||
| 177 | installed: false, | ||
| 178 | version: null, | ||
| 179 | status: "warning", | ||
| 180 | auth: { status: "unknown" }, | ||
| 181 | message: "Grok is disabled in T3 Code settings.", | ||
| 182 | }, | ||
| 183 | }); | ||
| 184 | } | ||
| 185 | |||
| 186 | const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe( | ||
| 187 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), | ||
| 188 | Effect.result, | ||
| 189 | ); | ||
| 190 | |||
| 191 | if (Result.isFailure(versionResult)) { | ||
| 192 | const error = versionResult.failure; | ||
| 193 | return buildServerProvider({ | ||
| 194 | presentation: GROK_PRESENTATION, | ||
| 195 | enabled: grokSettings.enabled, | ||
| 196 | checkedAt, | ||
| 197 | models: fallbackModels, | ||
| 198 | probe: { | ||
| 199 | installed: !isCommandMissingCause(error), | ||
| 200 | version: null, | ||
| 201 | status: "error", | ||
| 202 | auth: { status: "unknown" }, | ||
| 203 | message: isCommandMissingCause(error) | ||
| 204 | ? "Grok CLI (`grok`) is not installed or not on PATH." | ||
| 205 | : `Failed to execute Grok CLI health check: ${error instanceof Error ? error.message : String(error)}.`, | ||
| 206 | }, | ||
| 207 | }); | ||
| 208 | } | ||
| 209 | |||
| 210 | if (Option.isNone(versionResult.success)) { | ||
| 211 | return buildServerProvider({ | ||
| 212 | presentation: GROK_PRESENTATION, | ||
| 213 | enabled: grokSettings.enabled, | ||
| 214 | checkedAt, | ||
| 215 | models: fallbackModels, | ||
| 216 | probe: { | ||
| 217 | installed: true, | ||
| 218 | version: null, | ||
| 219 | status: "error", | ||
| 220 | auth: { status: "unknown" }, | ||
| 221 | message: "Grok CLI is installed but timed out while running `grok --version`.", | ||
| 222 | }, | ||
| 223 | }); | ||
| 224 | } | ||
| 225 | |||
| 226 | const versionOutput = versionResult.success.value; | ||
| 227 | const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); | ||
| 228 | if (versionOutput.code !== 0) { | ||
| 229 | const detail = detailFromResult(versionOutput); | ||
| 230 | return buildServerProvider({ | ||
| 231 | presentation: GROK_PRESENTATION, | ||
| 232 | enabled: grokSettings.enabled, | ||
| 233 | checkedAt, | ||
| 234 | models: fallbackModels, | ||
| 235 | probe: { | ||
| 236 | installed: true, | ||
| 237 | version, | ||
| 238 | status: "error", | ||
| 239 | auth: { status: "unknown" }, | ||
| 240 | message: detail | ||
| 241 | ? `Grok CLI is installed but failed to run. ${detail}` | ||
| 242 | : "Grok CLI is installed but failed to run.", | ||
| 243 | }, | ||
| 244 | }); | ||
| 245 | } | ||
| 246 | |||
| 247 | const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( | ||
| 248 | Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), | ||
| 249 | Effect.exit, | ||
| 250 | ); | ||
| 251 | if (Exit.isFailure(discoveryExit)) { | ||
| 252 | const detail = Cause.pretty(discoveryExit.cause); | ||
| 253 | yield* Effect.logWarning("Grok ACP model discovery failed", { cause: detail }); | ||
| 254 | return buildServerProvider({ | ||
| 255 | presentation: GROK_PRESENTATION, | ||
| 256 | enabled: grokSettings.enabled, | ||
| 257 | checkedAt, | ||
| 258 | models: fallbackModels, | ||
| 259 | probe: { | ||
| 260 | installed: true, | ||
| 261 | version, | ||
| 262 | status: "error", | ||
| 263 | auth: { status: "unknown" }, | ||
| 264 | message: `Grok CLI is installed but ACP startup failed. ${detail}`, | ||
| 265 | }, | ||
| 266 | }); | ||
| 267 | } | ||
| 268 | if (Option.isNone(discoveryExit.value)) { | ||
| 269 | yield* Effect.logWarning( | ||
| 270 | `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, | ||
| 271 | ); | ||
| 272 | return buildServerProvider({ | ||
| 273 | presentation: GROK_PRESENTATION, | ||
| 274 | enabled: grokSettings.enabled, | ||
| 275 | checkedAt, | ||
| 276 | models: fallbackModels, | ||
| 277 | probe: { | ||
| 278 | installed: true, | ||
| 279 | version, | ||
| 280 | status: "error", | ||
| 281 | auth: { status: "unknown" }, | ||
| 282 | message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, | ||
| 283 | }, | ||
| 284 | }); | ||
| 285 | } | ||
| 286 | const discoveredModels = discoveryExit.value.value; | ||
| 287 | const models = | ||
| 288 | discoveredModels.length > 0 | ||
| 289 | ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) | ||
| 290 | : fallbackModels; | ||
| 291 | |||
| 292 | return buildServerProvider({ | ||
| 293 | presentation: GROK_PRESENTATION, | ||
| 294 | enabled: grokSettings.enabled, | ||
| 295 | checkedAt, | ||
| 296 | models, | ||
| 297 | probe: { | ||
| 298 | installed: true, | ||
| 299 | version, | ||
| 300 | status: "ready", | ||
| 301 | auth: { status: "unknown" }, | ||
| 302 | }, | ||
| 303 | }); | ||
| 304 | }); | ||
| 305 | |||
| 306 | export const enrichGrokSnapshot = (input: { | ||
| 307 | readonly snapshot: ServerProvider; | ||
| 308 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | ||
| 309 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | ||
| 310 | readonly httpClient: HttpClient.HttpClient; | ||
| 311 | }): Effect.Effect<void> => { | ||
| 312 | const { snapshot, publishSnapshot } = input; | ||
| 313 | |||
| 314 | return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe( | ||
| 315 | Effect.provideService(HttpClient.HttpClient, input.httpClient), | ||
| 316 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | ||
| 317 | Effect.catchCause((cause) => | ||
| 318 | Effect.logWarning("Grok version advisory enrichment failed", { | ||
| 319 | cause: Cause.pretty(cause), | ||
| 320 | }), | ||
| 321 | ), | ||
| 322 | Effect.asVoid, | ||
| 323 | ); | ||
| 324 | }; | ||
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+47-8| ... | @@ -10,7 +10,7 @@ | ... | @@ -10,7 +10,7 @@ |
| 10 | * | 10 | * |
| 11 | * 2. **Many drivers, one registry** — the "all drivers slice" describe | 11 | * 2. **Many drivers, one registry** — the "all drivers slice" describe |
| 12 | * block below configures one instance of every shipped driver | 12 | * block below configures one instance of every shipped driver |
| 13 | * (`codex`, `claudeAgent`, `cursor`, `opencode`) in a single | 13 | * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single |
| 14 | * `ProviderInstanceConfigMap` and asserts the registry boots them all | 14 | * `ProviderInstanceConfigMap` and asserts the registry boots them all |
| 15 | * without cross-contamination. This proves the driver SPI is uniform | 15 | * without cross-contamination. This proves the driver SPI is uniform |
| 16 | * across every provider — any driver plugs into the registry through | 16 | * across every provider — any driver plugs into the registry through |
| ... | @@ -18,7 +18,7 @@ | ... | @@ -18,7 +18,7 @@ |
| 18 | * | 18 | * |
| 19 | * Every instance in these tests is configured with `enabled: false` so the | 19 | * Every instance in these tests is configured with `enabled: false` so the |
| 20 | * provider-status checks short-circuit to pending/disabled snapshots | 20 | * provider-status checks short-circuit to pending/disabled snapshots |
| 21 | * without trying to spawn real `codex` / `claude` / `agent` / `opencode` | 21 | * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` |
| 22 | * binaries. That keeps the assertions focused on registry routing | 22 | * binaries. That keeps the assertions focused on registry routing |
| 23 | * behaviour rather than the runtime details of each provider. | 23 | * behaviour rather than the runtime details of each provider. |
| 24 | */ | 24 | */ |
| ... | @@ -28,6 +28,7 @@ import { | ... | @@ -28,6 +28,7 @@ import { |
| 28 | type ClaudeSettings, | 28 | type ClaudeSettings, |
| 29 | type CodexSettings, | 29 | type CodexSettings, |
| 30 | type CursorSettings, | 30 | type CursorSettings, |
| 31 | type GrokSettings, | ||
| 31 | type OpenCodeSettings, | 32 | type OpenCodeSettings, |
| 32 | ProviderDriverKind, | 33 | ProviderDriverKind, |
| 33 | type ProviderInstanceConfigMap, | 34 | type ProviderInstanceConfigMap, |
| ... | @@ -41,6 +42,7 @@ import { ServerConfig } from "../../config.ts"; | ... | @@ -41,6 +42,7 @@ import { ServerConfig } from "../../config.ts"; |
| 41 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; | 42 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; |
| 42 | import { CodexDriver } from "../Drivers/CodexDriver.ts"; | 43 | import { CodexDriver } from "../Drivers/CodexDriver.ts"; |
| 43 | import { CursorDriver } from "../Drivers/CursorDriver.ts"; | 44 | import { CursorDriver } from "../Drivers/CursorDriver.ts"; |
| 45 | import { GrokDriver } from "../Drivers/GrokDriver.ts"; | ||
| 44 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; | 46 | import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; |
| 45 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | 47 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; |
| 46 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | 48 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| ... | @@ -79,6 +81,13 @@ const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings => | ... | @@ -79,6 +81,13 @@ const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings => |
| 79 | ...overrides, | 81 | ...overrides, |
| 80 | }); | 82 | }); |
| 81 | 83 | ||
| 84 | const makeGrokConfig = (overrides: Partial<GrokSettings>): GrokSettings => ({ | ||
| 85 | enabled: false, | ||
| 86 | binaryPath: "grok", | ||
| 87 | customModels: [], | ||
| 88 | ...overrides, | ||
| 89 | }); | ||
| 90 | |||
| 82 | const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({ | 91 | const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({ |
| 83 | enabled: false, | 92 | enabled: false, |
| 84 | binaryPath: "opencode", | 93 | binaryPath: "opencode", |
| ... | @@ -218,7 +227,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { | ... | @@ -218,7 +227,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { |
| 218 | }); | 227 | }); |
| 219 | 228 | ||
| 220 | describe("ProviderInstanceRegistryLive — all drivers slice", () => { | 229 | describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 221 | // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem + | 230 | // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem + |
| 222 | // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` | 231 | // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` |
| 223 | // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. | 232 | // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. |
| 224 | // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via | 233 | // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via |
| ... | @@ -244,11 +253,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -244,11 +253,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 244 | const codexId = ProviderInstanceId.make("codex_default"); | 253 | const codexId = ProviderInstanceId.make("codex_default"); |
| 245 | const claudeId = ProviderInstanceId.make("claude_default"); | 254 | const claudeId = ProviderInstanceId.make("claude_default"); |
| 246 | const cursorId = ProviderInstanceId.make("cursor_default"); | 255 | const cursorId = ProviderInstanceId.make("cursor_default"); |
| 256 | const grokId = ProviderInstanceId.make("grok_default"); | ||
| 247 | const openCodeId = ProviderInstanceId.make("opencode_default"); | 257 | const openCodeId = ProviderInstanceId.make("opencode_default"); |
| 248 | 258 | ||
| 249 | const codexDriverKind = ProviderDriverKind.make("codex"); | 259 | const codexDriverKind = ProviderDriverKind.make("codex"); |
| 250 | const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); | 260 | const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); |
| 251 | const cursorDriverKind = ProviderDriverKind.make("cursor"); | 261 | const cursorDriverKind = ProviderDriverKind.make("cursor"); |
| 262 | const grokDriverKind = ProviderDriverKind.make("grok"); | ||
| 252 | const openCodeDriverKind = ProviderDriverKind.make("opencode"); | 263 | const openCodeDriverKind = ProviderDriverKind.make("opencode"); |
| 253 | 264 | ||
| 254 | const configMap: ProviderInstanceConfigMap = { | 265 | const configMap: ProviderInstanceConfigMap = { |
| ... | @@ -273,6 +284,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -273,6 +284,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 273 | enabled: false, | 284 | enabled: false, |
| 274 | config: makeCursorConfig({}), | 285 | config: makeCursorConfig({}), |
| 275 | }, | 286 | }, |
| 287 | [grokId]: { | ||
| 288 | driver: grokDriverKind, | ||
| 289 | displayName: "Grok", | ||
| 290 | enabled: false, | ||
| 291 | config: makeGrokConfig({}), | ||
| 292 | }, | ||
| 276 | [openCodeId]: { | 293 | [openCodeId]: { |
| 277 | driver: openCodeDriverKind, | 294 | driver: openCodeDriverKind, |
| 278 | displayName: "OpenCode", | 295 | displayName: "OpenCode", |
| ... | @@ -282,7 +299,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -282,7 +299,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 282 | }; | 299 | }; |
| 283 | 300 | ||
| 284 | const { registry } = yield* makeProviderInstanceRegistry({ | 301 | const { registry } = yield* makeProviderInstanceRegistry({ |
| 285 | drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver], | 302 | drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], |
| 286 | configMap, | 303 | configMap, |
| 287 | }); | 304 | }); |
| 288 | 305 | ||
| ... | @@ -292,9 +309,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -292,9 +309,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 292 | expect(unavailable).toEqual([]); | 309 | expect(unavailable).toEqual([]); |
| 293 | 310 | ||
| 294 | const instances = yield* registry.listInstances; | 311 | const instances = yield* registry.listInstances; |
| 295 | expect(instances).toHaveLength(4); | 312 | expect(instances).toHaveLength(5); |
| 296 | expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( | 313 | expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( |
| 297 | [codexId, claudeId, cursorId, openCodeId].toSorted(), | 314 | [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), |
| 298 | ); | 315 | ); |
| 299 | 316 | ||
| 300 | // Instance lookup by id resolves each instance to its own bundle — | 317 | // Instance lookup by id resolves each instance to its own bundle — |
| ... | @@ -303,14 +320,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -303,14 +320,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 303 | const codex = yield* registry.getInstance(codexId); | 320 | const codex = yield* registry.getInstance(codexId); |
| 304 | const claude = yield* registry.getInstance(claudeId); | 321 | const claude = yield* registry.getInstance(claudeId); |
| 305 | const cursor = yield* registry.getInstance(cursorId); | 322 | const cursor = yield* registry.getInstance(cursorId); |
| 323 | const grok = yield* registry.getInstance(grokId); | ||
| 306 | const openCode = yield* registry.getInstance(openCodeId); | 324 | const openCode = yield* registry.getInstance(openCodeId); |
| 307 | expect(codex?.driverKind).toBe(codexDriverKind); | 325 | expect(codex?.driverKind).toBe(codexDriverKind); |
| 308 | expect(claude?.driverKind).toBe(claudeDriverKind); | 326 | expect(claude?.driverKind).toBe(claudeDriverKind); |
| 309 | expect(cursor?.driverKind).toBe(cursorDriverKind); | 327 | expect(cursor?.driverKind).toBe(cursorDriverKind); |
| 328 | expect(grok?.driverKind).toBe(grokDriverKind); | ||
| 310 | expect(openCode?.driverKind).toBe(openCodeDriverKind); | 329 | expect(openCode?.driverKind).toBe(openCodeDriverKind); |
| 311 | expect(codex?.displayName).toBe("Codex"); | 330 | expect(codex?.displayName).toBe("Codex"); |
| 312 | expect(claude?.displayName).toBe("Claude"); | 331 | expect(claude?.displayName).toBe("Claude"); |
| 313 | expect(cursor?.displayName).toBe("Cursor"); | 332 | expect(cursor?.displayName).toBe("Cursor"); |
| 333 | expect(grok?.displayName).toBe("Grok"); | ||
| 314 | expect(openCode?.displayName).toBe("OpenCode"); | 334 | expect(openCode?.displayName).toBe("OpenCode"); |
| 315 | 335 | ||
| 316 | // Every instance owns its own set of closures — no sharing across | 336 | // Every instance owns its own set of closures — no sharing across |
| ... | @@ -318,16 +338,29 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -318,16 +338,29 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 318 | // distinct references even when two instances happen to share a | 338 | // distinct references even when two instances happen to share a |
| 319 | // trait (e.g. Cursor + others all use a stub-or-real | 339 | // trait (e.g. Cursor + others all use a stub-or-real |
| 320 | // `textGeneration`; they must still be different object values). | 340 | // `textGeneration`; they must still be different object values). |
| 321 | const adapters = [codex!.adapter, claude!.adapter, cursor!.adapter, openCode!.adapter]; | 341 | const adapters = [ |
| 342 | codex!.adapter, | ||
| 343 | claude!.adapter, | ||
| 344 | cursor!.adapter, | ||
| 345 | grok!.adapter, | ||
| 346 | openCode!.adapter, | ||
| 347 | ]; | ||
| 322 | expect(new Set(adapters).size).toBe(adapters.length); | 348 | expect(new Set(adapters).size).toBe(adapters.length); |
| 323 | const textGenerations = [ | 349 | const textGenerations = [ |
| 324 | codex!.textGeneration, | 350 | codex!.textGeneration, |
| 325 | claude!.textGeneration, | 351 | claude!.textGeneration, |
| 326 | cursor!.textGeneration, | 352 | cursor!.textGeneration, |
| 353 | grok!.textGeneration, | ||
| 327 | openCode!.textGeneration, | 354 | openCode!.textGeneration, |
| 328 | ]; | 355 | ]; |
| 329 | expect(new Set(textGenerations).size).toBe(textGenerations.length); | 356 | expect(new Set(textGenerations).size).toBe(textGenerations.length); |
| 330 | const snapshots = [codex!.snapshot, claude!.snapshot, cursor!.snapshot, openCode!.snapshot]; | 357 | const snapshots = [ |
| 358 | codex!.snapshot, | ||
| 359 | claude!.snapshot, | ||
| 360 | cursor!.snapshot, | ||
| 361 | grok!.snapshot, | ||
| 362 | openCode!.snapshot, | ||
| 363 | ]; | ||
| 331 | expect(new Set(snapshots).size).toBe(snapshots.length); | 364 | expect(new Set(snapshots).size).toBe(snapshots.length); |
| 332 | 365 | ||
| 333 | // Snapshots identify themselves by `instanceId` + `driver` so | 366 | // Snapshots identify themselves by `instanceId` + `driver` so |
| ... | @@ -356,6 +389,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -356,6 +389,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 356 | `${cursorDriverKind}:instance:${cursorId}`, | 389 | `${cursorDriverKind}:instance:${cursorId}`, |
| 357 | ); | 390 | ); |
| 358 | 391 | ||
| 392 | const grokSnapshot = yield* grok!.snapshot.getSnapshot; | ||
| 393 | expect(grokSnapshot.instanceId).toBe(grokId); | ||
| 394 | expect(grokSnapshot.driver).toBe(grokDriverKind); | ||
| 395 | expect(grokSnapshot.enabled).toBe(false); | ||
| 396 | expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); | ||
| 397 | |||
| 359 | const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; | 398 | const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; |
| 360 | expect(openCodeSnapshot.instanceId).toBe(openCodeId); | 399 | expect(openCodeSnapshot.instanceId).toBe(openCodeId); |
| 361 | expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); | 400 | expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+7| ... | @@ -992,6 +992,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -992,6 +992,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 992 | codex: { enabled: false }, | 992 | codex: { enabled: false }, |
| 993 | claudeAgent: { enabled: false }, | 993 | claudeAgent: { enabled: false }, |
| 994 | cursor: { enabled: false }, | 994 | cursor: { enabled: false }, |
| 995 | grok: { enabled: false }, | ||
| 995 | opencode: { enabled: false }, | 996 | opencode: { enabled: false }, |
| 996 | }, | 997 | }, |
| 997 | // `providerInstances` keys are branded `ProviderInstanceId`; | 998 | // `providerInstances` keys are branded `ProviderInstanceId`; |
| ... | @@ -1086,6 +1087,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1086,6 +1087,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1086 | codex: { enabled: true, binaryPath: firstMissing }, | 1087 | codex: { enabled: true, binaryPath: firstMissing }, |
| 1087 | claudeAgent: { enabled: false }, | 1088 | claudeAgent: { enabled: false }, |
| 1088 | cursor: { enabled: false }, | 1089 | cursor: { enabled: false }, |
| 1090 | grok: { enabled: false }, | ||
| 1089 | opencode: { enabled: false }, | 1091 | opencode: { enabled: false }, |
| 1090 | }, | 1092 | }, |
| 1091 | }), | 1093 | }), |
| ... | @@ -1186,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1186,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1186 | codex: { enabled: false }, | 1188 | codex: { enabled: false }, |
| 1187 | claudeAgent: { enabled: false }, | 1189 | claudeAgent: { enabled: false }, |
| 1188 | cursor: { enabled: false }, | 1190 | cursor: { enabled: false }, |
| 1191 | grok: { enabled: false }, | ||
| 1189 | opencode: { enabled: false }, | 1192 | opencode: { enabled: false }, |
| 1190 | }, | 1193 | }, |
| 1191 | providerInstances: { | 1194 | providerInstances: { |
| ... | @@ -1245,6 +1248,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1245,6 +1248,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1245 | cursor: { | 1248 | cursor: { |
| 1246 | enabled: false, | 1249 | enabled: false, |
| 1247 | }, | 1250 | }, |
| 1251 | grok: { | ||
| 1252 | enabled: false, | ||
| 1253 | }, | ||
| 1248 | }, | 1254 | }, |
| 1249 | }), | 1255 | }), |
| 1250 | ), | 1256 | ), |
| ... | @@ -1305,6 +1311,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1305,6 +1311,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1305 | "claudeAgent", | 1311 | "claudeAgent", |
| 1306 | "codex", | 1312 | "codex", |
| 1307 | "cursor", | 1313 | "cursor", |
| 1314 | "grok", | ||
| 1308 | "opencode", | 1315 | "opencode", |
| 1309 | ]); | 1316 | ]); |
| 1310 | assert.strictEqual(cursorProvider?.enabled, false); | 1317 | assert.strictEqual(cursorProvider?.enabled, false); |
apps/server/src/provider/Services/GrokAdapter.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | /** | ||
| 2 | * GrokAdapter — shape type for the Grok provider adapter. | ||
| 3 | * | ||
| 4 | * The driver model ({@link ../Drivers/GrokDriver}) bundles one adapter per | ||
| 5 | * instance as a captured closure, so this module only retains the shape | ||
| 6 | * interface as a naming anchor for the driver bundle. | ||
| 7 | * | ||
| 8 | * @module GrokAdapter | ||
| 9 | */ | ||
| 10 | import type { ProviderAdapterError } from "../Errors.ts"; | ||
| 11 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | ||
| 12 | |||
| 13 | /** | ||
| 14 | * GrokAdapterShape — per-instance Grok adapter contract. | ||
| 15 | */ | ||
| 16 | export interface GrokAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {} | ||
apps/server/src/provider/acp/AcpSessionRuntime.ts+17| ... | @@ -105,6 +105,9 @@ export interface AcpSessionRuntimeShape { | ... | @@ -105,6 +105,9 @@ export interface AcpSessionRuntimeShape { |
| 105 | value: string | boolean, | 105 | value: string | boolean, |
| 106 | ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>; | 106 | ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>; |
| 107 | readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; | 107 | readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>; |
| 108 | readonly setSessionModel: ( | ||
| 109 | modelId: string, | ||
| 110 | ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>; | ||
| 108 | readonly request: ( | 111 | readonly request: ( |
| 109 | method: string, | 112 | method: string, |
| 110 | payload: unknown, | 113 | payload: unknown, |
| ... | @@ -546,6 +549,20 @@ const makeAcpSessionRuntime = ( | ... | @@ -546,6 +549,20 @@ const makeAcpSessionRuntime = ( |
| 546 | Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), | 549 | Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), |
| 547 | Effect.asVoid, | 550 | Effect.asVoid, |
| 548 | ), | 551 | ), |
| 552 | setSessionModel: (modelId) => | ||
| 553 | getStartedState.pipe( | ||
| 554 | Effect.flatMap((started) => { | ||
| 555 | const requestPayload = { | ||
| 556 | sessionId: started.sessionId, | ||
| 557 | modelId, | ||
| 558 | } satisfies EffectAcpSchema.SetSessionModelRequest; | ||
| 559 | return runLoggedRequest( | ||
| 560 | "session/set_model", | ||
| 561 | requestPayload, | ||
| 562 | acp.agent.setSessionModel(requestPayload), | ||
| 563 | ); | ||
| 564 | }), | ||
| 565 | ), | ||
| 549 | request: (method, payload) => | 566 | request: (method, payload) => |
| 550 | runLoggedRequest(method, payload, acp.raw.request(method, payload)), | 567 | runLoggedRequest(method, payload, acp.raw.request(method, payload)), |
| 551 | notify: acp.raw.notify, | 568 | notify: acp.raw.notify, |
apps/server/src/provider/acp/GrokAcpCliProbe.test.ts created+69| ... | @@ -0,0 +1,69 @@ | ||
| 1 | /** | ||
| 2 | * Optional integration check against a real `grok agent stdio` install. | ||
| 3 | * Enable with: T3_GROK_ACP_PROBE=1 bun run test GrokAcpCliProbe | ||
| 4 | * | ||
| 5 | * The probe assumes either `XAI_API_KEY` is set in the environment or | ||
| 6 | * the user has previously run `grok login`. Without credentials the | ||
| 7 | * agent's `authenticate` request will fail and the test will surface | ||
| 8 | * the error. | ||
| 9 | */ | ||
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 11 | import { it } from "@effect/vitest"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 14 | import { describe, expect } from "vite-plus/test"; | ||
| 15 | |||
| 16 | import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts"; | ||
| 17 | |||
| 18 | const makeProbeRuntime = Effect.gen(function* () { | ||
| 19 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 20 | return yield* makeGrokAcpRuntime({ | ||
| 21 | grokSettings: { binaryPath: "grok" }, | ||
| 22 | environment: process.env, | ||
| 23 | childProcessSpawner, | ||
| 24 | cwd: process.cwd(), | ||
| 25 | clientInfo: { name: "t3-grok-probe", version: "0.0.0" }, | ||
| 26 | }); | ||
| 27 | }); | ||
| 28 | |||
| 29 | describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () => { | ||
| 30 | it.effect("initialize and authenticate against real grok agent stdio", () => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const runtime = yield* makeProbeRuntime; | ||
| 33 | const started = yield* runtime.start(); | ||
| 34 | expect(started.initializeResult).toBeDefined(); | ||
| 35 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 36 | ); | ||
| 37 | |||
| 38 | it.effect("session/new advertises typed SessionModelState with at least one model", () => | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const runtime = yield* makeProbeRuntime; | ||
| 41 | const started = yield* runtime.start(); | ||
| 42 | const result = started.sessionSetupResult; | ||
| 43 | |||
| 44 | expect(typeof started.sessionId).toBe("string"); | ||
| 45 | |||
| 46 | // Modern grok-shell advertises models through the typed | ||
| 47 | // `SessionModelState` field, not via a `configOptions` entry. | ||
| 48 | // If this assertion fails the upstream surface has regressed. | ||
| 49 | const models = result.models; | ||
| 50 | expect(models).toBeDefined(); | ||
| 51 | expect(typeof models?.currentModelId).toBe("string"); | ||
| 52 | expect(models?.availableModels.length ?? 0).toBeGreaterThan(0); | ||
| 53 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 54 | ); | ||
| 55 | |||
| 56 | it.effect("session/set_model accepts a no-op switch to the current model", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const runtime = yield* makeProbeRuntime; | ||
| 59 | const started = yield* runtime.start(); | ||
| 60 | const currentModelId = started.sessionSetupResult.models?.currentModelId?.trim(); | ||
| 61 | expect(currentModelId).toBeDefined(); | ||
| 62 | if (!currentModelId) return; | ||
| 63 | |||
| 64 | // No-op switch — selecting the model the session already runs on must | ||
| 65 | // succeed against every Grok build that implements `session/set_model`. | ||
| 66 | yield* runtime.setSessionModel(currentModelId); | ||
| 67 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 68 | ); | ||
| 69 | }); | ||
apps/server/src/provider/acp/GrokAcpSupport.test.ts created+109| ... | @@ -0,0 +1,109 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | applyGrokAcpModelSelection, | ||
| 7 | buildGrokAcpSpawnInput, | ||
| 8 | resolveGrokAcpBaseModelId, | ||
| 9 | } from "./GrokAcpSupport.ts"; | ||
| 10 | |||
| 11 | describe("resolveGrokAcpBaseModelId", () => { | ||
| 12 | it("normalizes empty and custom Grok model ids", () => { | ||
| 13 | expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build"); | ||
| 14 | expect(resolveGrokAcpBaseModelId(" ")).toBe("grok-build"); | ||
| 15 | expect(resolveGrokAcpBaseModelId(" grok-test-custom-model ")).toBe("grok-test-custom-model"); | ||
| 16 | }); | ||
| 17 | }); | ||
| 18 | |||
| 19 | describe("buildGrokAcpSpawnInput", () => { | ||
| 20 | it("passes the T3 Code referrer through Grok OAuth env", () => { | ||
| 21 | const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { | ||
| 22 | XAI_API_KEY: "secret", | ||
| 23 | GROK_OAUTH2_REFERRER: "other-client", | ||
| 24 | }); | ||
| 25 | |||
| 26 | expect(spawn).toEqual({ | ||
| 27 | command: "/usr/local/bin/grok", | ||
| 28 | args: ["agent", "stdio"], | ||
| 29 | cwd: "/tmp/project", | ||
| 30 | env: { | ||
| 31 | XAI_API_KEY: "secret", | ||
| 32 | GROK_OAUTH2_REFERRER: "t3code", | ||
| 33 | }, | ||
| 34 | }); | ||
| 35 | }); | ||
| 36 | }); | ||
| 37 | |||
| 38 | describe("applyGrokAcpModelSelection", () => { | ||
| 39 | const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { | ||
| 40 | const modelCalls: Array<string> = []; | ||
| 41 | const runtime = { | ||
| 42 | setSessionModel: (modelId: string) => | ||
| 43 | Effect.gen(function* () { | ||
| 44 | modelCalls.push(modelId); | ||
| 45 | if (failure) return yield* failure; | ||
| 46 | return {}; | ||
| 47 | }), | ||
| 48 | }; | ||
| 49 | return { runtime, modelCalls }; | ||
| 50 | }; | ||
| 51 | |||
| 52 | it("calls session/set_model when the requested model differs from current", async () => { | ||
| 53 | const { runtime, modelCalls } = makeRecordingRuntime(); | ||
| 54 | const result = await Effect.runPromise( | ||
| 55 | applyGrokAcpModelSelection({ | ||
| 56 | runtime, | ||
| 57 | currentModelId: "grok-build", | ||
| 58 | requestedModelId: "grok-mock-alt", | ||
| 59 | mapError: (cause) => cause.message, | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | expect(modelCalls).toEqual(["grok-mock-alt"]); | ||
| 63 | expect(result).toBe("grok-mock-alt"); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("skips set_model when requested matches current", async () => { | ||
| 67 | const { runtime, modelCalls } = makeRecordingRuntime(); | ||
| 68 | const result = await Effect.runPromise( | ||
| 69 | applyGrokAcpModelSelection({ | ||
| 70 | runtime, | ||
| 71 | currentModelId: "grok-build", | ||
| 72 | requestedModelId: "grok-build", | ||
| 73 | mapError: (cause) => cause.message, | ||
| 74 | }), | ||
| 75 | ); | ||
| 76 | expect(modelCalls).toEqual([]); | ||
| 77 | expect(result).toBe("grok-build"); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("skips set_model when no model is requested", async () => { | ||
| 81 | const { runtime, modelCalls } = makeRecordingRuntime(); | ||
| 82 | const result = await Effect.runPromise( | ||
| 83 | applyGrokAcpModelSelection({ | ||
| 84 | runtime, | ||
| 85 | currentModelId: "grok-build", | ||
| 86 | requestedModelId: undefined, | ||
| 87 | mapError: (cause) => cause.message, | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | expect(modelCalls).toEqual([]); | ||
| 91 | expect(result).toBe("grok-build"); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("propagates session/set_model failures via mapError", async () => { | ||
| 95 | const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known"); | ||
| 96 | const { runtime } = makeRecordingRuntime(failure); | ||
| 97 | const error = await Effect.runPromise( | ||
| 98 | Effect.flip( | ||
| 99 | applyGrokAcpModelSelection({ | ||
| 100 | runtime, | ||
| 101 | currentModelId: "grok-build", | ||
| 102 | requestedModelId: "grok-mock-alt", | ||
| 103 | mapError: (cause) => cause.message, | ||
| 104 | }), | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | expect(error).toBe(failure.message); | ||
| 108 | }); | ||
| 109 | }); | ||
apps/server/src/provider/acp/GrokAcpSupport.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Scope from "effect/Scope"; | ||
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 6 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 7 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 8 | import { normalizeModelSlug } from "@t3tools/shared/model"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | AcpSessionRuntime, | ||
| 12 | type AcpSessionRuntimeOptions, | ||
| 13 | type AcpSessionRuntimeShape, | ||
| 14 | type AcpSpawnInput, | ||
| 15 | } from "./AcpSessionRuntime.ts"; | ||
| 16 | |||
| 17 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | ||
| 18 | const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; | ||
| 19 | const T3_CODE_OAUTH_REFERRER = "t3code"; | ||
| 20 | const GROK_AUTH_METHOD_API_KEY = "xai.api_key"; | ||
| 21 | const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token"; | ||
| 22 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); | ||
| 23 | |||
| 24 | type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">; | ||
| 25 | |||
| 26 | interface GrokAcpRuntimeInput extends Omit< | ||
| 27 | AcpSessionRuntimeOptions, | ||
| 28 | "authMethodId" | "clientCapabilities" | "spawn" | ||
| 29 | > { | ||
| 30 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | ||
| 31 | readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; | ||
| 32 | readonly environment?: NodeJS.ProcessEnv; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function buildGrokAcpSpawnInput( | ||
| 36 | grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, | ||
| 37 | cwd: string, | ||
| 38 | environment?: NodeJS.ProcessEnv, | ||
| 39 | ): AcpSpawnInput { | ||
| 40 | return { | ||
| 41 | command: grokSettings?.binaryPath || "grok", | ||
| 42 | args: ["agent", "stdio"], | ||
| 43 | cwd, | ||
| 44 | env: { | ||
| 45 | ...environment, | ||
| 46 | [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER, | ||
| 47 | }, | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string { | ||
| 52 | return environment?.[GROK_API_KEY_ENV]?.trim() | ||
| 53 | ? GROK_AUTH_METHOD_API_KEY | ||
| 54 | : GROK_AUTH_METHOD_CACHED_TOKEN; | ||
| 55 | } | ||
| 56 | |||
| 57 | export const makeGrokAcpRuntime = ( | ||
| 58 | input: GrokAcpRuntimeInput, | ||
| 59 | ): Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope> => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const acpContext = yield* Layer.build( | ||
| 62 | AcpSessionRuntime.layer({ | ||
| 63 | ...input, | ||
| 64 | spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), | ||
| 65 | authMethodId: resolveGrokAuthMethodId(input.environment), | ||
| 66 | }).pipe( | ||
| 67 | Layer.provide( | ||
| 68 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), | ||
| 69 | ), | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); | ||
| 73 | }); | ||
| 74 | |||
| 75 | export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { | ||
| 76 | const trimmed = model?.trim(); | ||
| 77 | const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build"; | ||
| 78 | return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; | ||
| 79 | } | ||
| 80 | |||
| 81 | export function currentGrokModelIdFromSessionSetup( | ||
| 82 | sessionSetupResult: | ||
| 83 | | EffectAcpSchema.LoadSessionResponse | ||
| 84 | | EffectAcpSchema.NewSessionResponse | ||
| 85 | | EffectAcpSchema.ResumeSessionResponse, | ||
| 86 | ): string | undefined { | ||
| 87 | return sessionSetupResult.models?.currentModelId?.trim() || undefined; | ||
| 88 | } | ||
| 89 | |||
| 90 | export function applyGrokAcpModelSelection<E>(input: { | ||
| 91 | readonly runtime: Pick<AcpSessionRuntimeShape, "setSessionModel">; | ||
| 92 | readonly currentModelId: string | undefined; | ||
| 93 | readonly requestedModelId: string | undefined; | ||
| 94 | readonly mapError: (cause: EffectAcpErrors.AcpError) => E; | ||
| 95 | }): Effect.Effect<string | undefined, E> { | ||
| 96 | const shouldSwitchModel = | ||
| 97 | input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; | ||
| 98 | if (!shouldSwitchModel) { | ||
| 99 | return Effect.succeed(input.currentModelId); | ||
| 100 | } | ||
| 101 | return input.runtime | ||
| 102 | .setSessionModel(input.requestedModelId) | ||
| 103 | .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); | ||
| 104 | } | ||
apps/server/src/provider/acp/XAiAcpExtension.test.ts created+250| ... | @@ -0,0 +1,250 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | extractXAiAskUserQuestions, | ||
| 6 | makeXAiAskUserQuestionCancelledResponse, | ||
| 7 | makeXAiAskUserQuestionResponse, | ||
| 8 | XAiAskUserQuestionRequest, | ||
| 9 | } from "./XAiAcpExtension.ts"; | ||
| 10 | |||
| 11 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); | ||
| 12 | |||
| 13 | describe("XAiAcpExtension", () => { | ||
| 14 | it("extracts questions from the real xAI ask_user_question payload shape", () => { | ||
| 15 | const questions = extractXAiAskUserQuestions({ | ||
| 16 | sessionId: "session-1", | ||
| 17 | toolCallId: "tool-call-1", | ||
| 18 | mode: "default", | ||
| 19 | questions: [ | ||
| 20 | { | ||
| 21 | id: "scope", | ||
| 22 | question: "Which scope should Grok use?", | ||
| 23 | options: [ | ||
| 24 | { label: "Workspace", description: "Use the current workspace" }, | ||
| 25 | { label: "Session", description: "Only use this session" }, | ||
| 26 | ], | ||
| 27 | }, | ||
| 28 | ], | ||
| 29 | }); | ||
| 30 | |||
| 31 | expect(questions).toEqual([ | ||
| 32 | { | ||
| 33 | id: "scope", | ||
| 34 | header: "Question", | ||
| 35 | question: "Which scope should Grok use?", | ||
| 36 | multiSelect: false, | ||
| 37 | options: [ | ||
| 38 | { label: "Workspace", description: "Use the current workspace" }, | ||
| 39 | { label: "Session", description: "Only use this session" }, | ||
| 40 | ], | ||
| 41 | }, | ||
| 42 | ]); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("extracts questions from wrapped _x.ai extension payloads", () => { | ||
| 46 | const payload = { | ||
| 47 | method: "_x.ai/ask_user_question", | ||
| 48 | params: { | ||
| 49 | sessionId: "session-1", | ||
| 50 | toolCallId: "tool-call-1", | ||
| 51 | mode: "plan", | ||
| 52 | questions: [ | ||
| 53 | { | ||
| 54 | question: "Which changes should be included?", | ||
| 55 | multiSelect: true, | ||
| 56 | options: [{ label: "Tests" }, { label: "Docs" }], | ||
| 57 | }, | ||
| 58 | ], | ||
| 59 | }, | ||
| 60 | }; | ||
| 61 | const decoded = decodeXAiAskUserQuestionRequest(payload); | ||
| 62 | const questions = extractXAiAskUserQuestions(decoded); | ||
| 63 | |||
| 64 | expect(questions).toEqual([ | ||
| 65 | { | ||
| 66 | id: "Which changes should be included?", | ||
| 67 | header: "Question", | ||
| 68 | question: "Which changes should be included?", | ||
| 69 | multiSelect: true, | ||
| 70 | options: [ | ||
| 71 | { label: "Tests", description: "Tests" }, | ||
| 72 | { label: "Docs", description: "Docs" }, | ||
| 73 | ], | ||
| 74 | }, | ||
| 75 | ]); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it("treats nullable multiSelect from Grok as single-select", () => { | ||
| 79 | const questions = extractXAiAskUserQuestions({ | ||
| 80 | sessionId: "session-1", | ||
| 81 | toolCallId: "tool-call-1", | ||
| 82 | mode: "default", | ||
| 83 | questions: [ | ||
| 84 | { | ||
| 85 | question: "Which label should Grok use?", | ||
| 86 | multiSelect: null, | ||
| 87 | options: [ | ||
| 88 | { label: "Alpha", description: "Use the Alpha label" }, | ||
| 89 | { label: "Beta", description: "Use the Beta label" }, | ||
| 90 | { label: "Other", description: "Use the Other label" }, | ||
| 91 | ], | ||
| 92 | }, | ||
| 93 | ], | ||
| 94 | }); | ||
| 95 | |||
| 96 | expect(questions).toEqual([ | ||
| 97 | { | ||
| 98 | id: "Which label should Grok use?", | ||
| 99 | header: "Question", | ||
| 100 | question: "Which label should Grok use?", | ||
| 101 | multiSelect: false, | ||
| 102 | options: [ | ||
| 103 | { label: "Alpha", description: "Use the Alpha label" }, | ||
| 104 | { label: "Beta", description: "Use the Beta label" }, | ||
| 105 | { label: "Other", description: "Use the Other label" }, | ||
| 106 | ], | ||
| 107 | }, | ||
| 108 | ]); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("maps UI question ids back to xAI question text in accepted responses", () => { | ||
| 112 | const response = makeXAiAskUserQuestionResponse( | ||
| 113 | { | ||
| 114 | sessionId: "session-1", | ||
| 115 | toolCallId: "tool-call-1", | ||
| 116 | mode: "default", | ||
| 117 | questions: [ | ||
| 118 | { | ||
| 119 | id: "scope", | ||
| 120 | question: "Which scope should Grok use?", | ||
| 121 | options: [ | ||
| 122 | { label: "workspace", description: "Use the current workspace" }, | ||
| 123 | { label: "session", description: "Only use this session" }, | ||
| 124 | ], | ||
| 125 | }, | ||
| 126 | ], | ||
| 127 | }, | ||
| 128 | { scope: "workspace" }, | ||
| 129 | ); | ||
| 130 | |||
| 131 | expect(response).toEqual({ | ||
| 132 | outcome: "accepted", | ||
| 133 | answers: { | ||
| 134 | "Which scope should Grok use?": ["workspace"], | ||
| 135 | }, | ||
| 136 | }); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("orders accepted answers by the original xAI question order", () => { | ||
| 140 | const response = makeXAiAskUserQuestionResponse( | ||
| 141 | { | ||
| 142 | sessionId: "session-1", | ||
| 143 | toolCallId: "tool-call-1", | ||
| 144 | mode: "default", | ||
| 145 | questions: [ | ||
| 146 | { | ||
| 147 | id: "first", | ||
| 148 | question: "First question?", | ||
| 149 | options: [{ label: "A", description: "A" }], | ||
| 150 | }, | ||
| 151 | { | ||
| 152 | id: "second", | ||
| 153 | question: "Second question?", | ||
| 154 | options: [{ label: "B", description: "B" }], | ||
| 155 | }, | ||
| 156 | ], | ||
| 157 | }, | ||
| 158 | { | ||
| 159 | second: "B", | ||
| 160 | first: "A", | ||
| 161 | }, | ||
| 162 | ); | ||
| 163 | |||
| 164 | expect(Object.keys(response.answers)).toEqual(["First question?", "Second question?"]); | ||
| 165 | expect(response).toMatchObject({ | ||
| 166 | outcome: "accepted", | ||
| 167 | answers: { | ||
| 168 | "First question?": ["A"], | ||
| 169 | "Second question?": ["B"], | ||
| 170 | }, | ||
| 171 | }); | ||
| 172 | }); | ||
| 173 | |||
| 174 | it("encodes typed custom answers as xAI Other annotations", () => { | ||
| 175 | const response = makeXAiAskUserQuestionResponse( | ||
| 176 | { | ||
| 177 | method: "x.ai/ask_user_question", | ||
| 178 | params: { | ||
| 179 | sessionId: "session-1", | ||
| 180 | toolCallId: "tool-call-1", | ||
| 181 | mode: "default", | ||
| 182 | questions: [ | ||
| 183 | { | ||
| 184 | question: "Which ice cream flavor?", | ||
| 185 | options: [ | ||
| 186 | { label: "vanilla", description: "Vanilla flavor" }, | ||
| 187 | { label: "chocolate", description: "Chocolate flavor" }, | ||
| 188 | ], | ||
| 189 | }, | ||
| 190 | ], | ||
| 191 | }, | ||
| 192 | }, | ||
| 193 | { "Which ice cream flavor?": "pistachio" }, | ||
| 194 | ); | ||
| 195 | |||
| 196 | expect(response).toEqual({ | ||
| 197 | outcome: "accepted", | ||
| 198 | answers: { | ||
| 199 | "Which ice cream flavor?": ["Other"], | ||
| 200 | }, | ||
| 201 | annotations: { | ||
| 202 | "Which ice cream flavor?": { | ||
| 203 | notes: "pistachio", | ||
| 204 | }, | ||
| 205 | }, | ||
| 206 | }); | ||
| 207 | }); | ||
| 208 | |||
| 209 | it("encodes interrupted dialogs as xAI cancelled responses", () => { | ||
| 210 | expect(makeXAiAskUserQuestionCancelledResponse()).toEqual({ | ||
| 211 | outcome: "cancelled", | ||
| 212 | }); | ||
| 213 | }); | ||
| 214 | |||
| 215 | it("does not echo preview annotations for multi-select answers", () => { | ||
| 216 | const response = makeXAiAskUserQuestionResponse( | ||
| 217 | { | ||
| 218 | sessionId: "session-1", | ||
| 219 | toolCallId: "tool-call-1", | ||
| 220 | mode: "default", | ||
| 221 | questions: [ | ||
| 222 | { | ||
| 223 | question: "Which files should Grok touch?", | ||
| 224 | multiSelect: true, | ||
| 225 | options: [ | ||
| 226 | { | ||
| 227 | label: "Tests", | ||
| 228 | description: "Update tests", | ||
| 229 | preview: "test preview", | ||
| 230 | }, | ||
| 231 | { | ||
| 232 | label: "Docs", | ||
| 233 | description: "Update docs", | ||
| 234 | preview: "docs preview", | ||
| 235 | }, | ||
| 236 | ], | ||
| 237 | }, | ||
| 238 | ], | ||
| 239 | }, | ||
| 240 | { "Which files should Grok touch?": ["Tests", "Docs"] }, | ||
| 241 | ); | ||
| 242 | |||
| 243 | expect(response).toEqual({ | ||
| 244 | outcome: "accepted", | ||
| 245 | answers: { | ||
| 246 | "Which files should Grok touch?": ["Tests", "Docs"], | ||
| 247 | }, | ||
| 248 | }); | ||
| 249 | }); | ||
| 250 | }); | ||
apps/server/src/provider/acp/XAiAcpExtension.ts created+173| ... | @@ -0,0 +1,173 @@ | ||
| 1 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | const XAiAskUserQuestionOption = Schema.Struct({ | ||
| 5 | label: Schema.String, | ||
| 6 | description: Schema.optional(Schema.String), | ||
| 7 | preview: Schema.optional(Schema.String), | ||
| 8 | id: Schema.optional(Schema.String), | ||
| 9 | }); | ||
| 10 | |||
| 11 | const XAiAskUserQuestion = Schema.Struct({ | ||
| 12 | id: Schema.optional(Schema.String), | ||
| 13 | question: Schema.String, | ||
| 14 | options: Schema.Array(XAiAskUserQuestionOption), | ||
| 15 | multiSelect: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 16 | }); | ||
| 17 | |||
| 18 | const XAiAskUserQuestionParams = Schema.Struct({ | ||
| 19 | sessionId: Schema.String, | ||
| 20 | toolCallId: Schema.String, | ||
| 21 | questions: Schema.Array(XAiAskUserQuestion), | ||
| 22 | mode: Schema.Literals(["default", "plan"]), | ||
| 23 | }); | ||
| 24 | |||
| 25 | const XAiWrappedAskUserQuestionParams = Schema.Struct({ | ||
| 26 | method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]), | ||
| 27 | params: XAiAskUserQuestionParams, | ||
| 28 | }); | ||
| 29 | |||
| 30 | export const XAiAskUserQuestionRequest = Schema.Union([ | ||
| 31 | XAiAskUserQuestionParams, | ||
| 32 | XAiWrappedAskUserQuestionParams, | ||
| 33 | ]); | ||
| 34 | |||
| 35 | type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type; | ||
| 36 | type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type; | ||
| 37 | |||
| 38 | function trimmed(value: string | undefined): string | undefined { | ||
| 39 | const text = value?.trim(); | ||
| 40 | return text && text.length > 0 ? text : undefined; | ||
| 41 | } | ||
| 42 | |||
| 43 | function unwrapAskUserQuestionParams( | ||
| 44 | params: XAiAskUserQuestionRequest, | ||
| 45 | ): XAiAskUserQuestionRequestParams { | ||
| 46 | return "params" in params ? params.params : params; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function extractXAiAskUserQuestions( | ||
| 50 | params: XAiAskUserQuestionRequest, | ||
| 51 | ): ReadonlyArray<UserInputQuestion> { | ||
| 52 | return unwrapAskUserQuestionParams(params).questions.map((question) => ({ | ||
| 53 | id: question.id ?? question.question, | ||
| 54 | header: "Question", | ||
| 55 | question: question.question, | ||
| 56 | multiSelect: question.multiSelect === true, | ||
| 57 | options: | ||
| 58 | question.options.length > 0 | ||
| 59 | ? question.options.map((option) => ({ | ||
| 60 | label: option.label, | ||
| 61 | description: option.description ?? option.label, | ||
| 62 | })) | ||
| 63 | : [{ label: "OK", description: "Continue" }], | ||
| 64 | })); | ||
| 65 | } | ||
| 66 | |||
| 67 | interface XAiAskUserQuestionAnnotation { | ||
| 68 | readonly preview?: string; | ||
| 69 | readonly notes?: string; | ||
| 70 | } | ||
| 71 | |||
| 72 | interface XAiAskUserQuestionAcceptedResponse { | ||
| 73 | readonly outcome: "accepted"; | ||
| 74 | readonly answers: Record<string, ReadonlyArray<string>>; | ||
| 75 | readonly annotations?: Record<string, XAiAskUserQuestionAnnotation>; | ||
| 76 | } | ||
| 77 | |||
| 78 | interface XAiAskUserQuestionCancelledResponse { | ||
| 79 | readonly outcome: "cancelled"; | ||
| 80 | } | ||
| 81 | |||
| 82 | export type XAiAskUserQuestionResponse = | ||
| 83 | | XAiAskUserQuestionAcceptedResponse | ||
| 84 | | XAiAskUserQuestionCancelledResponse; | ||
| 85 | |||
| 86 | interface NormalizedXAiAnswer { | ||
| 87 | readonly questionText: string; | ||
| 88 | readonly selectedLabels: ReadonlyArray<string>; | ||
| 89 | readonly annotation?: XAiAskUserQuestionAnnotation; | ||
| 90 | } | ||
| 91 | |||
| 92 | function answerValues(answer: unknown): ReadonlyArray<string> { | ||
| 93 | if (Array.isArray(answer)) { | ||
| 94 | return answer.flatMap((entry) => { | ||
| 95 | const text = typeof entry === "string" ? trimmed(entry) : undefined; | ||
| 96 | return text ? [text] : []; | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | const text = typeof answer === "string" ? trimmed(answer) : undefined; | ||
| 100 | return text ? [text] : []; | ||
| 101 | } | ||
| 102 | |||
| 103 | function normalizeAnswerForXAi( | ||
| 104 | question: XAiAskUserQuestionRequestParams["questions"][number], | ||
| 105 | answer: unknown, | ||
| 106 | ): NormalizedXAiAnswer | undefined { | ||
| 107 | const values = answerValues(answer); | ||
| 108 | if (values.length === 0) { | ||
| 109 | return undefined; | ||
| 110 | } | ||
| 111 | |||
| 112 | const optionByLabel = new Map(question.options.map((option) => [option.label, option])); | ||
| 113 | const resolvedValues = values.map((value) => ({ | ||
| 114 | value, | ||
| 115 | option: optionByLabel.get(value), | ||
| 116 | })); | ||
| 117 | const selectedLabels = resolvedValues.flatMap(({ option }) => (option ? [option.label] : [])); | ||
| 118 | const notes = resolvedValues.flatMap(({ option, value }) => (option ? [] : [value])); | ||
| 119 | const preview = | ||
| 120 | question.multiSelect === true | ||
| 121 | ? undefined | ||
| 122 | : resolvedValues.map(({ option }) => trimmed(option?.preview)).find((value) => value); | ||
| 123 | |||
| 124 | const annotation = | ||
| 125 | preview || notes.length > 0 | ||
| 126 | ? { | ||
| 127 | ...(preview ? { preview } : {}), | ||
| 128 | ...(notes.length > 0 ? { notes: notes.join("\n") } : {}), | ||
| 129 | } | ||
| 130 | : undefined; | ||
| 131 | |||
| 132 | return { | ||
| 133 | questionText: question.question, | ||
| 134 | selectedLabels: selectedLabels.length > 0 ? selectedLabels : ["Other"], | ||
| 135 | ...(annotation ? { annotation } : {}), | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | |||
| 139 | function findQuestionAnswer( | ||
| 140 | answers: ProviderUserInputAnswers, | ||
| 141 | question: XAiAskUserQuestionRequestParams["questions"][number], | ||
| 142 | ): unknown { | ||
| 143 | const key = question.id ?? question.question; | ||
| 144 | return answers[key] ?? answers[question.question]; | ||
| 145 | } | ||
| 146 | |||
| 147 | export function makeXAiAskUserQuestionResponse( | ||
| 148 | params: XAiAskUserQuestionRequest, | ||
| 149 | answers: ProviderUserInputAnswers, | ||
| 150 | ): XAiAskUserQuestionAcceptedResponse { | ||
| 151 | const questions = unwrapAskUserQuestionParams(params).questions; | ||
| 152 | const normalized = questions.flatMap((question) => { | ||
| 153 | const entry = normalizeAnswerForXAi(question, findQuestionAnswer(answers, question)); | ||
| 154 | return entry ? [entry] : []; | ||
| 155 | }); | ||
| 156 | const annotations = Object.fromEntries( | ||
| 157 | normalized.flatMap((entry) => | ||
| 158 | entry.annotation ? [[entry.questionText, entry.annotation] as const] : [], | ||
| 159 | ), | ||
| 160 | ); | ||
| 161 | |||
| 162 | return { | ||
| 163 | outcome: "accepted", | ||
| 164 | answers: Object.fromEntries( | ||
| 165 | normalized.map((entry) => [entry.questionText, entry.selectedLabels]), | ||
| 166 | ), | ||
| 167 | ...(Object.keys(annotations).length > 0 ? { annotations } : {}), | ||
| 168 | }; | ||
| 169 | } | ||
| 170 | |||
| 171 | export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse { | ||
| 172 | return { outcome: "cancelled" }; | ||
| 173 | } | ||
apps/server/src/provider/builtInDrivers.ts+3| ... | @@ -23,6 +23,7 @@ | ... | @@ -23,6 +23,7 @@ |
| 23 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; | 23 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; |
| 24 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; | 24 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 25 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; | 25 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; |
| 26 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; | ||
| 26 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; | 27 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; |
| 27 | import type { AnyProviderDriver } from "./ProviderDriver.ts"; | 28 | import type { AnyProviderDriver } from "./ProviderDriver.ts"; |
| 28 | 29 | ||
| ... | @@ -35,6 +36,7 @@ export type BuiltInDriversEnv = | ... | @@ -35,6 +36,7 @@ export type BuiltInDriversEnv = |
| 35 | | ClaudeDriverEnv | 36 | | ClaudeDriverEnv |
| 36 | | CodexDriverEnv | 37 | | CodexDriverEnv |
| 37 | | CursorDriverEnv | 38 | | CursorDriverEnv |
| 39 | | GrokDriverEnv | ||
| 38 | | OpenCodeDriverEnv; | 40 | | OpenCodeDriverEnv; |
| 39 | 41 | ||
| 40 | /** | 42 | /** |
| ... | @@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv | ... | @@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv |
| 46 | CodexDriver, | 48 | CodexDriver, |
| 47 | ClaudeDriver, | 49 | ClaudeDriver, |
| 48 | CursorDriver, | 50 | CursorDriver, |
| 51 | GrokDriver, | ||
| 49 | OpenCodeDriver, | 52 | OpenCodeDriver, |
| 50 | ]; | 53 | ]; |
apps/server/src/provider/providerSnapshot.ts+4| ... | @@ -45,6 +45,7 @@ export interface ServerProviderPresentation { | ... | @@ -45,6 +45,7 @@ export interface ServerProviderPresentation { |
| 45 | readonly displayName: string; | 45 | readonly displayName: string; |
| 46 | readonly badgeLabel?: string; | 46 | readonly badgeLabel?: string; |
| 47 | readonly showInteractionModeToggle?: boolean; | 47 | readonly showInteractionModeToggle?: boolean; |
| 48 | readonly requiresNewThreadForModelChange?: boolean; | ||
| 48 | } | 49 | } |
| 49 | 50 | ||
| 50 | export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">; | 51 | export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">; |
| ... | @@ -214,6 +215,9 @@ export function buildServerProvider(input: { | ... | @@ -214,6 +215,9 @@ export function buildServerProvider(input: { |
| 214 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" | 215 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" |
| 215 | ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } | 216 | ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } |
| 216 | : {}), | 217 | : {}), |
| 218 | ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" | ||
| 219 | ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } | ||
| 220 | : {}), | ||
| 217 | enabled: input.enabled, | 221 | enabled: input.enabled, |
| 218 | installed: input.probe.installed, | 222 | installed: input.probe.installed, |
| 219 | version: input.probe.version, | 223 | version: input.probe.version, |
apps/server/src/provider/testUtils/providerRegistryMock.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; | ||
| 2 | import type { ServerProvider } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 7 | |||
| 8 | export const makeProviderRegistryMock = ( | ||
| 9 | providers: ReadonlyArray<ServerProvider> = [], | ||
| 10 | ): ProviderRegistryShape => ({ | ||
| 11 | getProviders: Effect.succeed(providers), | ||
| 12 | refresh: () => Effect.succeed(providers), | ||
| 13 | refreshInstance: () => Effect.succeed(providers), | ||
| 14 | getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => | ||
| 15 | Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), | ||
| 16 | setProviderMaintenanceActionState: () => Effect.succeed(providers), | ||
| 17 | streamChanges: Stream.empty, | ||
| 18 | }); | ||
| 19 | |||
| 20 | export const makeProviderRegistryLayer = (providers: ReadonlyArray<ServerProvider> = []) => | ||
| 21 | Layer.succeed(ProviderRegistry, makeProviderRegistryMock(providers)); | ||
apps/server/src/serverSettings.ts+2| ... | @@ -13,6 +13,7 @@ | ... | @@ -13,6 +13,7 @@ |
| 13 | import { | 13 | import { |
| 14 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | 14 | DEFAULT_GIT_TEXT_GENERATION_MODEL, |
| 15 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | 15 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 16 | DEFAULT_MODEL_BY_PROVIDER, | ||
| 16 | DEFAULT_SERVER_SETTINGS, | 17 | DEFAULT_SERVER_SETTINGS, |
| 17 | isProviderDriverKind, | 18 | isProviderDriverKind, |
| 18 | type ModelSelection, | 19 | type ModelSelection, |
| ... | @@ -207,6 +208,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting | ... | @@ -207,6 +208,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting |
| 207 | instanceId: ProviderInstanceId.make(fallback), | 208 | instanceId: ProviderInstanceId.make(fallback), |
| 208 | model: | 209 | model: |
| 209 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? | 210 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? |
| 211 | DEFAULT_MODEL_BY_PROVIDER[fallback] ?? | ||
| 210 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | 212 | DEFAULT_GIT_TEXT_GENERATION_MODEL, |
| 211 | } satisfies ModelSelection, | 213 | } satisfies ModelSelection, |
| 212 | }; | 214 | }; |
apps/server/src/textGeneration/GrokTextGeneration.test.ts created+234| ... | @@ -0,0 +1,234 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as path from "node:path"; | ||
| 3 | import * as os from "node:os"; | ||
| 4 | import { fileURLToPath } from "node:url"; | ||
| 5 | import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| 6 | |||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { it } from "@effect/vitest"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 13 | import { expect } from "vite-plus/test"; | ||
| 14 | import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import { ServerConfig } from "../config.ts"; | ||
| 17 | import { type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 18 | import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; | ||
| 19 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | ||
| 20 | |||
| 21 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| 22 | const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); | ||
| 23 | |||
| 24 | function shellSingleQuote(value: string): string { | ||
| 25 | return `'${value.replaceAll("'", `'"'"'`)}'`; | ||
| 26 | } | ||
| 27 | |||
| 28 | const GrokTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 29 | prefix: "t3code-grok-text-generation-test-", | ||
| 30 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 31 | |||
| 32 | function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string { | ||
| 33 | const binDir = path.join(dir, "bin"); | ||
| 34 | const grokPath = path.join(binDir, "grok"); | ||
| 35 | mkdirSync(binDir, { recursive: true }); | ||
| 36 | writeFileSync( | ||
| 37 | grokPath, | ||
| 38 | [ | ||
| 39 | "#!/bin/sh", | ||
| 40 | ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), | ||
| 41 | 'if [ "$1" != "agent" ] || [ "$2" != "stdio" ]; then', | ||
| 42 | ' printf "%s\\n" "unexpected args: $*" >&2', | ||
| 43 | " exit 11", | ||
| 44 | "fi", | ||
| 45 | `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`, | ||
| 46 | "", | ||
| 47 | ].join("\n"), | ||
| 48 | "utf8", | ||
| 49 | ); | ||
| 50 | chmodSync(grokPath, 0o755); | ||
| 51 | return grokPath; | ||
| 52 | } | ||
| 53 | |||
| 54 | function withFakeAcpGrok<A, E, R>( | ||
| 55 | env: Record<string, string>, | ||
| 56 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 57 | ) { | ||
| 58 | return Effect.gen(function* () { | ||
| 59 | const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-acp-")); | ||
| 60 | yield* Effect.addFinalizer(() => | ||
| 61 | Effect.sync(() => { | ||
| 62 | rmSync(tempDir, { recursive: true, force: true }); | ||
| 63 | }), | ||
| 64 | ); | ||
| 65 | const binaryPath = makeAcpGrokWrapper(tempDir, env); | ||
| 66 | const config = decodeGrokSettings({ binaryPath }); | ||
| 67 | const textGeneration = yield* makeGrokTextGeneration(config); | ||
| 68 | return yield* effectFn(textGeneration); | ||
| 69 | }).pipe(Effect.scoped); | ||
| 70 | } | ||
| 71 | |||
| 72 | function readJsonRpcRequests( | ||
| 73 | filePath: string, | ||
| 74 | ): ReadonlyArray<{ readonly method?: string; readonly params?: Record<string, unknown> }> { | ||
| 75 | return readFileSync(filePath, "utf8") | ||
| 76 | .trim() | ||
| 77 | .split("\n") | ||
| 78 | .filter((line) => line.length > 0) | ||
| 79 | .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> }); | ||
| 80 | } | ||
| 81 | |||
| 82 | it.layer(GrokTextGenerationTestLayer)("GrokTextGeneration", (it) => { | ||
| 83 | it.effect("uses ACP with disabled tool capabilities and forwards the requested model id", () => { | ||
| 84 | const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-log-")); | ||
| 85 | const requestLogPath = path.join(requestLogDir, "requests.ndjson"); | ||
| 86 | |||
| 87 | return withFakeAcpGrok( | ||
| 88 | { | ||
| 89 | T3_ACP_REQUEST_LOG_PATH: requestLogPath, | ||
| 90 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | ||
| 91 | subject: "Add Grok provider", | ||
| 92 | body: "Wire up the ACP runtime and headless text generation path.", | ||
| 93 | }), | ||
| 94 | }, | ||
| 95 | (textGeneration) => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 98 | cwd: process.cwd(), | ||
| 99 | branch: "feature/grok", | ||
| 100 | stagedSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts", | ||
| 101 | stagedPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts", | ||
| 102 | modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-mock-alt"), | ||
| 103 | }); | ||
| 104 | |||
| 105 | expect(generated.subject).toBe("Add Grok provider"); | ||
| 106 | expect(generated.body).toBe("Wire up the ACP runtime and headless text generation path."); | ||
| 107 | |||
| 108 | const requests = readJsonRpcRequests(requestLogPath); | ||
| 109 | expect( | ||
| 110 | requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, | ||
| 111 | ).toMatchObject({ | ||
| 112 | fs: { readTextFile: false, writeTextFile: false }, | ||
| 113 | terminal: false, | ||
| 114 | }); | ||
| 115 | expect( | ||
| 116 | requests.some( | ||
| 117 | (request) => | ||
| 118 | request.method === "session/set_model" && | ||
| 119 | request.params?.modelId === "grok-mock-alt", | ||
| 120 | ), | ||
| 121 | ).toBe(true); | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | }); | ||
| 125 | |||
| 126 | it.effect("extracts the JSON object when Grok wraps it in conversational text", () => | ||
| 127 | withFakeAcpGrok( | ||
| 128 | { | ||
| 129 | T3_ACP_PROMPT_RESPONSE_TEXT: | ||
| 130 | "Sure! Here's a thread title:\n\n" + | ||
| 131 | JSON.stringify({ title: "Investigate failing CI" }) + | ||
| 132 | "\n\nLet me know if you need anything else.", | ||
| 133 | }, | ||
| 134 | (textGeneration) => | ||
| 135 | Effect.gen(function* () { | ||
| 136 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 137 | cwd: process.cwd(), | ||
| 138 | message: "the lint job is red", | ||
| 139 | modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-mock-alt"), | ||
| 140 | }); | ||
| 141 | expect(generated.title).toBe("Investigate failing CI"); | ||
| 142 | }), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | |||
| 146 | it.effect("surfaces ACP request failures as text generation errors", () => | ||
| 147 | withFakeAcpGrok( | ||
| 148 | { | ||
| 149 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "unreachable" }), | ||
| 150 | }, | ||
| 151 | (textGeneration) => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | const error = yield* Effect.flip( | ||
| 154 | textGeneration.generateBranchName({ | ||
| 155 | cwd: process.cwd(), | ||
| 156 | message: "wire up grok", | ||
| 157 | modelSelection: createModelSelection( | ||
| 158 | ProviderInstanceId.make("grok"), | ||
| 159 | "missing-grok-model", | ||
| 160 | ), | ||
| 161 | }), | ||
| 162 | ); | ||
| 163 | expect(error._tag).toBe("TextGenerationError"); | ||
| 164 | expect(error.detail).toContain("Grok ACP base model"); | ||
| 165 | }), | ||
| 166 | ), | ||
| 167 | ); | ||
| 168 | |||
| 169 | it.effect("fails with TextGenerationError when output is empty", () => | ||
| 170 | withFakeAcpGrok( | ||
| 171 | { | ||
| 172 | T3_ACP_PROMPT_RESPONSE_TEXT: " \n ", | ||
| 173 | }, | ||
| 174 | (textGeneration) => | ||
| 175 | Effect.gen(function* () { | ||
| 176 | const error = yield* Effect.flip( | ||
| 177 | textGeneration.generateThreadTitle({ | ||
| 178 | cwd: process.cwd(), | ||
| 179 | message: "anything", | ||
| 180 | modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | expect(error._tag).toBe("TextGenerationError"); | ||
| 184 | expect(error.detail).toMatch(/empty/i); | ||
| 185 | }), | ||
| 186 | ), | ||
| 187 | ); | ||
| 188 | |||
| 189 | it.effect("decodes a structured PR title + body", () => | ||
| 190 | withFakeAcpGrok( | ||
| 191 | { | ||
| 192 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | ||
| 193 | title: "feat(grok): wire up session/set_model", | ||
| 194 | body: "## Summary\n- Replace `-m` spawn flag with the typed ACP `session/set_model`.\n- Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT` into a validation error.", | ||
| 195 | }), | ||
| 196 | }, | ||
| 197 | (textGeneration) => | ||
| 198 | Effect.gen(function* () { | ||
| 199 | const generated = yield* textGeneration.generatePrContent({ | ||
| 200 | cwd: process.cwd(), | ||
| 201 | baseBranch: "main", | ||
| 202 | headBranch: "feat/grok-provider", | ||
| 203 | commitSummary: "feat: add grok provider", | ||
| 204 | diffSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts", | ||
| 205 | diffPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts", | ||
| 206 | modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), | ||
| 207 | }); | ||
| 208 | |||
| 209 | expect(generated.title).toBe("feat(grok): wire up session/set_model"); | ||
| 210 | expect(generated.body).toContain("Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT`"); | ||
| 211 | }), | ||
| 212 | ), | ||
| 213 | ); | ||
| 214 | |||
| 215 | it.effect("fails with TextGenerationError when output is unparseable JSON", () => | ||
| 216 | withFakeAcpGrok( | ||
| 217 | { | ||
| 218 | T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model", | ||
| 219 | }, | ||
| 220 | (textGeneration) => | ||
| 221 | Effect.gen(function* () { | ||
| 222 | const error = yield* Effect.flip( | ||
| 223 | textGeneration.generateThreadTitle({ | ||
| 224 | cwd: process.cwd(), | ||
| 225 | message: "anything", | ||
| 226 | modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), | ||
| 227 | }), | ||
| 228 | ); | ||
| 229 | expect(error._tag).toBe("TextGenerationError"); | ||
| 230 | expect(error.detail).toMatch(/invalid structured output/i); | ||
| 231 | }), | ||
| 232 | ), | ||
| 233 | ); | ||
| 234 | }); | ||
apps/server/src/textGeneration/GrokTextGeneration.ts created+272| ... | @@ -0,0 +1,272 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import * as Ref from "effect/Ref"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 6 | import type * as EffectAcpErrors from "effect-acp/errors"; | ||
| 7 | |||
| 8 | import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; | ||
| 9 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 10 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 11 | |||
| 12 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 13 | import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 14 | import { | ||
| 15 | buildBranchNamePrompt, | ||
| 16 | buildCommitMessagePrompt, | ||
| 17 | buildPrContentPrompt, | ||
| 18 | buildThreadTitlePrompt, | ||
| 19 | } from "./TextGenerationPrompts.ts"; | ||
| 20 | import { | ||
| 21 | sanitizeCommitSubject, | ||
| 22 | sanitizePrTitle, | ||
| 23 | sanitizeThreadTitle, | ||
| 24 | } from "./TextGenerationUtils.ts"; | ||
| 25 | import { | ||
| 26 | applyGrokAcpModelSelection, | ||
| 27 | currentGrokModelIdFromSessionSetup, | ||
| 28 | makeGrokAcpRuntime, | ||
| 29 | resolveGrokAcpBaseModelId, | ||
| 30 | } from "../provider/acp/GrokAcpSupport.ts"; | ||
| 31 | |||
| 32 | const GROK_TIMEOUT_MS = 180_000; | ||
| 33 | |||
| 34 | function mapGrokAcpError( | ||
| 35 | operation: | ||
| 36 | | "generateCommitMessage" | ||
| 37 | | "generatePrContent" | ||
| 38 | | "generateBranchName" | ||
| 39 | | "generateThreadTitle", | ||
| 40 | detail: string, | ||
| 41 | cause: unknown, | ||
| 42 | ): TextGenerationError { | ||
| 43 | return new TextGenerationError({ | ||
| 44 | operation, | ||
| 45 | detail, | ||
| 46 | ...(cause !== undefined ? { cause } : {}), | ||
| 47 | }); | ||
| 48 | } | ||
| 49 | |||
| 50 | function isTextGenerationError(error: unknown): error is TextGenerationError { | ||
| 51 | return ( | ||
| 52 | typeof error === "object" && | ||
| 53 | error !== null && | ||
| 54 | "_tag" in error && | ||
| 55 | error._tag === "TextGenerationError" | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* ( | ||
| 60 | grokSettings: GrokSettings, | ||
| 61 | environment: NodeJS.ProcessEnv = process.env, | ||
| 62 | ) { | ||
| 63 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 64 | |||
| 65 | const runGrokJson = <S extends Schema.Top>({ | ||
| 66 | operation, | ||
| 67 | cwd, | ||
| 68 | prompt, | ||
| 69 | outputSchemaJson, | ||
| 70 | modelSelection, | ||
| 71 | }: { | ||
| 72 | operation: | ||
| 73 | | "generateCommitMessage" | ||
| 74 | | "generatePrContent" | ||
| 75 | | "generateBranchName" | ||
| 76 | | "generateThreadTitle"; | ||
| 77 | cwd: string; | ||
| 78 | prompt: string; | ||
| 79 | outputSchemaJson: S; | ||
| 80 | modelSelection: ModelSelection; | ||
| 81 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | ||
| 82 | Effect.gen(function* () { | ||
| 83 | const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model); | ||
| 84 | const outputRef = yield* Ref.make(""); | ||
| 85 | const runtime = yield* makeGrokAcpRuntime({ | ||
| 86 | grokSettings, | ||
| 87 | environment, | ||
| 88 | childProcessSpawner: commandSpawner, | ||
| 89 | cwd, | ||
| 90 | clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, | ||
| 91 | }); | ||
| 92 | |||
| 93 | yield* runtime.handleSessionUpdate((notification) => { | ||
| 94 | const update = notification.update; | ||
| 95 | if (update.sessionUpdate !== "agent_message_chunk") { | ||
| 96 | return Effect.void; | ||
| 97 | } | ||
| 98 | const content = update.content; | ||
| 99 | if (content.type !== "text") { | ||
| 100 | return Effect.void; | ||
| 101 | } | ||
| 102 | return Ref.update(outputRef, (current) => current + content.text); | ||
| 103 | }); | ||
| 104 | |||
| 105 | const promptResult = yield* Effect.gen(function* () { | ||
| 106 | const started = yield* runtime.start(); | ||
| 107 | yield* applyGrokAcpModelSelection({ | ||
| 108 | runtime, | ||
| 109 | currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), | ||
| 110 | requestedModelId: resolvedModel, | ||
| 111 | mapError: (cause) => | ||
| 112 | mapGrokAcpError( | ||
| 113 | operation, | ||
| 114 | "Failed to set Grok ACP base model for text generation.", | ||
| 115 | cause, | ||
| 116 | ), | ||
| 117 | }); | ||
| 118 | |||
| 119 | return yield* runtime.prompt({ | ||
| 120 | prompt: [{ type: "text", text: prompt }], | ||
| 121 | }); | ||
| 122 | }).pipe( | ||
| 123 | Effect.timeoutOption(GROK_TIMEOUT_MS), | ||
| 124 | Effect.flatMap( | ||
| 125 | Option.match({ | ||
| 126 | onNone: () => | ||
| 127 | Effect.fail( | ||
| 128 | new TextGenerationError({ operation, detail: "Grok ACP request timed out." }), | ||
| 129 | ), | ||
| 130 | onSome: (value) => Effect.succeed(value), | ||
| 131 | }), | ||
| 132 | ), | ||
| 133 | Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => | ||
| 134 | isTextGenerationError(cause) | ||
| 135 | ? cause | ||
| 136 | : mapGrokAcpError(operation, "Grok ACP request failed.", cause), | ||
| 137 | ), | ||
| 138 | ); | ||
| 139 | |||
| 140 | const trimmed = (yield* Ref.get(outputRef)).trim(); | ||
| 141 | if (!trimmed) { | ||
| 142 | return yield* new TextGenerationError({ | ||
| 143 | operation, | ||
| 144 | detail: | ||
| 145 | promptResult.stopReason === "cancelled" | ||
| 146 | ? "Grok ACP request was cancelled." | ||
| 147 | : "Grok Agent returned empty output.", | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | |||
| 151 | const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); | ||
| 152 | return yield* decodeOutput(extractJsonObject(trimmed)).pipe( | ||
| 153 | Effect.catchTag("SchemaError", (cause) => | ||
| 154 | Effect.fail( | ||
| 155 | new TextGenerationError({ | ||
| 156 | operation, | ||
| 157 | detail: "Grok Agent returned invalid structured output.", | ||
| 158 | cause, | ||
| 159 | }), | ||
| 160 | ), | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | }).pipe( | ||
| 164 | Effect.mapError((cause) => | ||
| 165 | isTextGenerationError(cause) | ||
| 166 | ? cause | ||
| 167 | : mapGrokAcpError(operation, "Grok ACP text generation failed.", cause), | ||
| 168 | ), | ||
| 169 | Effect.scoped, | ||
| 170 | ); | ||
| 171 | |||
| 172 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 173 | "GrokTextGeneration.generateCommitMessage", | ||
| 174 | )(function* (input) { | ||
| 175 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 176 | branch: input.branch, | ||
| 177 | stagedSummary: input.stagedSummary, | ||
| 178 | stagedPatch: input.stagedPatch, | ||
| 179 | includeBranch: input.includeBranch === true, | ||
| 180 | }); | ||
| 181 | |||
| 182 | const generated = yield* runGrokJson({ | ||
| 183 | operation: "generateCommitMessage", | ||
| 184 | cwd: input.cwd, | ||
| 185 | prompt, | ||
| 186 | outputSchemaJson: outputSchema, | ||
| 187 | modelSelection: input.modelSelection, | ||
| 188 | }); | ||
| 189 | |||
| 190 | return { | ||
| 191 | subject: sanitizeCommitSubject(generated.subject), | ||
| 192 | body: generated.body.trim(), | ||
| 193 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 194 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 195 | : {}), | ||
| 196 | }; | ||
| 197 | }); | ||
| 198 | |||
| 199 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 200 | "GrokTextGeneration.generatePrContent", | ||
| 201 | )(function* (input) { | ||
| 202 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 203 | baseBranch: input.baseBranch, | ||
| 204 | headBranch: input.headBranch, | ||
| 205 | commitSummary: input.commitSummary, | ||
| 206 | diffSummary: input.diffSummary, | ||
| 207 | diffPatch: input.diffPatch, | ||
| 208 | }); | ||
| 209 | |||
| 210 | const generated = yield* runGrokJson({ | ||
| 211 | operation: "generatePrContent", | ||
| 212 | cwd: input.cwd, | ||
| 213 | prompt, | ||
| 214 | outputSchemaJson: outputSchema, | ||
| 215 | modelSelection: input.modelSelection, | ||
| 216 | }); | ||
| 217 | |||
| 218 | return { | ||
| 219 | title: sanitizePrTitle(generated.title), | ||
| 220 | body: generated.body.trim(), | ||
| 221 | }; | ||
| 222 | }); | ||
| 223 | |||
| 224 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 225 | "GrokTextGeneration.generateBranchName", | ||
| 226 | )(function* (input) { | ||
| 227 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 228 | message: input.message, | ||
| 229 | attachments: input.attachments, | ||
| 230 | }); | ||
| 231 | |||
| 232 | const generated = yield* runGrokJson({ | ||
| 233 | operation: "generateBranchName", | ||
| 234 | cwd: input.cwd, | ||
| 235 | prompt, | ||
| 236 | outputSchemaJson: outputSchema, | ||
| 237 | modelSelection: input.modelSelection, | ||
| 238 | }); | ||
| 239 | |||
| 240 | return { | ||
| 241 | branch: sanitizeBranchFragment(generated.branch), | ||
| 242 | }; | ||
| 243 | }); | ||
| 244 | |||
| 245 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 246 | "GrokTextGeneration.generateThreadTitle", | ||
| 247 | )(function* (input) { | ||
| 248 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 249 | message: input.message, | ||
| 250 | attachments: input.attachments, | ||
| 251 | }); | ||
| 252 | |||
| 253 | const generated = yield* runGrokJson({ | ||
| 254 | operation: "generateThreadTitle", | ||
| 255 | cwd: input.cwd, | ||
| 256 | prompt, | ||
| 257 | outputSchemaJson: outputSchema, | ||
| 258 | modelSelection: input.modelSelection, | ||
| 259 | }); | ||
| 260 | |||
| 261 | return { | ||
| 262 | title: sanitizeThreadTitle(generated.title), | ||
| 263 | } satisfies ThreadTitleGenerationResult; | ||
| 264 | }); | ||
| 265 | |||
| 266 | return { | ||
| 267 | generateCommitMessage, | ||
| 268 | generatePrContent, | ||
| 269 | generateBranchName, | ||
| 270 | generateThreadTitle, | ||
| 271 | } satisfies TextGenerationShape; | ||
| 272 | }); | ||
apps/server/src/textGeneration/TextGeneration.ts+1-1| ... | @@ -10,7 +10,7 @@ import { | ... | @@ -10,7 +10,7 @@ import { |
| 10 | } from "../provider/Services/ProviderInstanceRegistry.ts"; | 10 | } from "../provider/Services/ProviderInstanceRegistry.ts"; |
| 11 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | 11 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; |
| 12 | 12 | ||
| 13 | export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; | 13 | export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; |
| 14 | 14 | ||
| 15 | export interface CommitMessageGenerationInput { | 15 | export interface CommitMessageGenerationInput { |
| 16 | cwd: string; | 16 | cwd: string; |
apps/web/src/clientPersistenceStorage.ts+1-1| ... | @@ -184,7 +184,7 @@ export function writeBrowserSavedEnvironmentSecret( | ... | @@ -184,7 +184,7 @@ export function writeBrowserSavedEnvironmentSecret( |
| 184 | return record; | 184 | return record; |
| 185 | } | 185 | } |
| 186 | found = true; | 186 | found = true; |
| 187 | const nextRecord = { | 187 | const nextRecord: BrowserSavedEnvironmentRecord = { |
| 188 | environmentId: record.environmentId, | 188 | environmentId: record.environmentId, |
| 189 | label: record.label, | 189 | label: record.label, |
| 190 | httpBaseUrl: record.httpBaseUrl, | 190 | httpBaseUrl: record.httpBaseUrl, |
apps/web/src/components/ChatView.logic.test.ts+68| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | buildExpiredTerminalContextToastCopy, | 16 | buildExpiredTerminalContextToastCopy, |
| 17 | createLocalDispatchSnapshot, | 17 | createLocalDispatchSnapshot, |
| 18 | deriveComposerSendState, | 18 | deriveComposerSendState, |
| 19 | getStartedThreadModelChangeBlockReason, | ||
| 19 | hasServerAcknowledgedLocalDispatch, | 20 | hasServerAcknowledgedLocalDispatch, |
| 20 | reconcileMountedTerminalThreadIds, | 21 | reconcileMountedTerminalThreadIds, |
| 21 | resolveSendEnvMode, | 22 | resolveSendEnvMode, |
| ... | @@ -90,6 +91,73 @@ describe("buildExpiredTerminalContextToastCopy", () => { | ... | @@ -90,6 +91,73 @@ describe("buildExpiredTerminalContextToastCopy", () => { |
| 90 | }); | 91 | }); |
| 91 | }); | 92 | }); |
| 92 | 93 | ||
| 94 | describe("getStartedThreadModelChangeBlockReason", () => { | ||
| 95 | const providers = [ | ||
| 96 | { | ||
| 97 | instanceId: ProviderInstanceId.make("codex"), | ||
| 98 | }, | ||
| 99 | { | ||
| 100 | instanceId: ProviderInstanceId.make("grok"), | ||
| 101 | requiresNewThreadForModelChange: true, | ||
| 102 | }, | ||
| 103 | ]; | ||
| 104 | |||
| 105 | it("allows model changes before a provider session has started", () => { | ||
| 106 | expect( | ||
| 107 | getStartedThreadModelChangeBlockReason({ | ||
| 108 | providers, | ||
| 109 | hasStartedSession: false, | ||
| 110 | currentModelSelection: { | ||
| 111 | instanceId: ProviderInstanceId.make("grok"), | ||
| 112 | model: "grok-build", | ||
| 113 | }, | ||
| 114 | nextModelSelection: { | ||
| 115 | instanceId: ProviderInstanceId.make("grok"), | ||
| 116 | model: "grok-other", | ||
| 117 | }, | ||
| 118 | }), | ||
| 119 | ).toBeNull(); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("allows unchanged model selections for restricted providers", () => { | ||
| 123 | expect( | ||
| 124 | getStartedThreadModelChangeBlockReason({ | ||
| 125 | providers, | ||
| 126 | hasStartedSession: true, | ||
| 127 | currentModelSelection: { | ||
| 128 | instanceId: ProviderInstanceId.make("grok"), | ||
| 129 | model: "grok-build", | ||
| 130 | }, | ||
| 131 | nextModelSelection: { | ||
| 132 | instanceId: ProviderInstanceId.make("grok"), | ||
| 133 | model: "grok-build", | ||
| 134 | }, | ||
| 135 | }), | ||
| 136 | ).toBeNull(); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("blocks started-session model changes when either provider requires a new thread", () => { | ||
| 140 | expect( | ||
| 141 | getStartedThreadModelChangeBlockReason({ | ||
| 142 | providers, | ||
| 143 | hasStartedSession: true, | ||
| 144 | currentModelSelection: { | ||
| 145 | instanceId: ProviderInstanceId.make("codex"), | ||
| 146 | model: "gpt-5.4", | ||
| 147 | }, | ||
| 148 | nextModelSelection: { | ||
| 149 | instanceId: ProviderInstanceId.make("grok"), | ||
| 150 | model: "grok-build", | ||
| 151 | }, | ||
| 152 | }), | ||
| 153 | ).toEqual({ | ||
| 154 | title: "Start a new chat to change models", | ||
| 155 | description: | ||
| 156 | "This provider does not allow switching models after a conversation has started.", | ||
| 157 | }); | ||
| 158 | }); | ||
| 159 | }); | ||
| 160 | |||
| 93 | describe("resolveSendEnvMode", () => { | 161 | describe("resolveSendEnvMode", () => { |
| 94 | it("keeps worktree mode for git repositories", () => { | 162 | it("keeps worktree mode for git repositories", () => { |
| 95 | expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); | 163 | expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); |
apps/web/src/components/ChatView.logic.ts+39| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | ProjectId, | 4 | ProjectId, |
| 5 | type ModelSelection, | 5 | type ModelSelection, |
| 6 | type ProviderDriverKind, | 6 | type ProviderDriverKind, |
| 7 | type ServerProvider, | ||
| 7 | type ScopedThreadRef, | 8 | type ScopedThreadRef, |
| 8 | type ThreadId, | 9 | type ThreadId, |
| 9 | type TurnId, | 10 | type TurnId, |
| ... | @@ -262,6 +263,44 @@ export function deriveLockedProvider(input: { | ... | @@ -262,6 +263,44 @@ export function deriveLockedProvider(input: { |
| 262 | return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; | 263 | return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; |
| 263 | } | 264 | } |
| 264 | 265 | ||
| 266 | export function getStartedThreadModelChangeBlockReason(input: { | ||
| 267 | providers: ReadonlyArray<Pick<ServerProvider, "instanceId" | "requiresNewThreadForModelChange">>; | ||
| 268 | hasStartedSession: boolean; | ||
| 269 | currentModelSelection: ModelSelection; | ||
| 270 | currentProviderInstanceId?: ModelSelection["instanceId"] | null | undefined; | ||
| 271 | nextModelSelection: ModelSelection; | ||
| 272 | }): { title: string; description: string } | null { | ||
| 273 | if (!input.hasStartedSession) { | ||
| 274 | return null; | ||
| 275 | } | ||
| 276 | const currentModelSelection = { | ||
| 277 | ...input.currentModelSelection, | ||
| 278 | instanceId: input.currentProviderInstanceId ?? input.currentModelSelection.instanceId, | ||
| 279 | }; | ||
| 280 | if ( | ||
| 281 | currentModelSelection.instanceId === input.nextModelSelection.instanceId && | ||
| 282 | currentModelSelection.model === input.nextModelSelection.model | ||
| 283 | ) { | ||
| 284 | return null; | ||
| 285 | } | ||
| 286 | const currentProvider = input.providers.find( | ||
| 287 | (snapshot) => snapshot.instanceId === currentModelSelection.instanceId, | ||
| 288 | ); | ||
| 289 | const nextProvider = input.providers.find( | ||
| 290 | (snapshot) => snapshot.instanceId === input.nextModelSelection.instanceId, | ||
| 291 | ); | ||
| 292 | if ( | ||
| 293 | currentProvider?.requiresNewThreadForModelChange !== true && | ||
| 294 | nextProvider?.requiresNewThreadForModelChange !== true | ||
| 295 | ) { | ||
| 296 | return null; | ||
| 297 | } | ||
| 298 | return { | ||
| 299 | title: "Start a new chat to change models", | ||
| 300 | description: "This provider does not allow switching models after a conversation has started.", | ||
| 301 | }; | ||
| 302 | } | ||
| 303 | |||
| 265 | export async function waitForStartedServerThread( | 304 | export async function waitForStartedServerThread( |
| 266 | threadRef: ScopedThreadRef, | 305 | threadRef: ScopedThreadRef, |
| 267 | timeoutMs = 1_000, | 306 | timeoutMs = 1_000, |
apps/web/src/components/ChatView.tsx+29-6| ... | @@ -163,6 +163,7 @@ import { | ... | @@ -163,6 +163,7 @@ import { |
| 163 | createLocalDispatchSnapshot, | 163 | createLocalDispatchSnapshot, |
| 164 | deriveComposerSendState, | 164 | deriveComposerSendState, |
| 165 | hasServerAcknowledgedLocalDispatch, | 165 | hasServerAcknowledgedLocalDispatch, |
| 166 | getStartedThreadModelChangeBlockReason, | ||
| 166 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | 167 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 167 | LastInvokedScriptByProjectSchema, | 168 | LastInvokedScriptByProjectSchema, |
| 168 | type LocalDispatchSnapshot, | 169 | type LocalDispatchSnapshot, |
| ... | @@ -1984,15 +1985,18 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1984,15 +1985,18 @@ export default function ChatView(props: ChatViewProps) { |
| 1984 | 1985 | ||
| 1985 | const focusComposer = useCallback(() => { | 1986 | const focusComposer = useCallback(() => { |
| 1986 | composerRef.current?.focusAtEnd(); | 1987 | composerRef.current?.focusAtEnd(); |
| 1987 | }, []); | 1988 | }, [composerRef]); |
| 1988 | const scheduleComposerFocus = useCallback(() => { | 1989 | const scheduleComposerFocus = useCallback(() => { |
| 1989 | window.requestAnimationFrame(() => { | 1990 | window.requestAnimationFrame(() => { |
| 1990 | focusComposer(); | 1991 | focusComposer(); |
| 1991 | }); | 1992 | }); |
| 1992 | }, [focusComposer]); | 1993 | }, [focusComposer]); |
| 1993 | const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { | 1994 | const addTerminalContextToDraft = useCallback( |
| 1994 | composerRef.current?.addTerminalContext(selection); | 1995 | (selection: TerminalContextSelection) => { |
| 1995 | }, []); | 1996 | composerRef.current?.addTerminalContext(selection); |
| 1997 | }, | ||
| 1998 | [composerRef], | ||
| 1999 | ); | ||
| 1996 | const setTerminalOpen = useCallback( | 2000 | const setTerminalOpen = useCallback( |
| 1997 | (open: boolean) => { | 2001 | (open: boolean) => { |
| 1998 | if (!activeThreadRef) return; | 2002 | if (!activeThreadRef) return; |
| ... | @@ -2771,6 +2775,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2771,6 +2775,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2771 | keybindings, | 2775 | keybindings, |
| 2772 | onToggleDiff, | 2776 | onToggleDiff, |
| 2773 | toggleTerminalVisibility, | 2777 | toggleTerminalVisibility, |
| 2778 | composerRef, | ||
| 2774 | ]); | 2779 | ]); |
| 2775 | 2780 | ||
| 2776 | const onRevertToTurnCount = useCallback( | 2781 | const onRevertToTurnCount = useCallback( |
| ... | @@ -3245,7 +3250,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3245,7 +3250,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3245 | promptRef.current = ""; | 3250 | promptRef.current = ""; |
| 3246 | composerRef.current?.resetCursorState({ cursor: 0 }); | 3251 | composerRef.current?.resetCursorState({ cursor: 0 }); |
| 3247 | }, | 3252 | }, |
| 3248 | [activePendingProgress?.activeQuestion, activePendingUserInput], | 3253 | [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], |
| 3249 | ); | 3254 | ); |
| 3250 | 3255 | ||
| 3251 | const onChangeActivePendingUserInputCustomAnswer = useCallback( | 3256 | const onChangeActivePendingUserInputCustomAnswer = useCallback( |
| ... | @@ -3279,7 +3284,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3279,7 +3284,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3279 | composerRef.current?.focusAt(nextCursor); | 3284 | composerRef.current?.focusAt(nextCursor); |
| 3280 | } | 3285 | } |
| 3281 | }, | 3286 | }, |
| 3282 | [activePendingUserInput], | 3287 | [activePendingUserInput, composerRef], |
| 3283 | ); | 3288 | ); |
| 3284 | 3289 | ||
| 3285 | const onAdvanceActivePendingUserInput = useCallback(() => { | 3290 | const onAdvanceActivePendingUserInput = useCallback(() => { |
| ... | @@ -3451,6 +3456,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3451,6 +3456,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3451 | setThreadError, | 3456 | setThreadError, |
| 3452 | autoOpenPlanSidebar, | 3457 | autoOpenPlanSidebar, |
| 3453 | environmentId, | 3458 | environmentId, |
| 3459 | composerRef, | ||
| 3454 | ], | 3460 | ], |
| 3455 | ); | 3461 | ); |
| 3456 | 3462 | ||
| ... | @@ -3588,6 +3594,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3588,6 +3594,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3588 | runtimeMode, | 3594 | runtimeMode, |
| 3589 | autoOpenPlanSidebar, | 3595 | autoOpenPlanSidebar, |
| 3590 | environmentId, | 3596 | environmentId, |
| 3597 | composerRef, | ||
| 3591 | ]); | 3598 | ]); |
| 3592 | 3599 | ||
| 3593 | const onProviderModelSelect = useCallback( | 3600 | const onProviderModelSelect = useCallback( |
| ... | @@ -3633,6 +3640,22 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3633,6 +3640,22 @@ export default function ChatView(props: ChatViewProps) { |
| 3633 | instanceId, | 3640 | instanceId, |
| 3634 | model: resolvedModel, | 3641 | model: resolvedModel, |
| 3635 | }; | 3642 | }; |
| 3643 | const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ | ||
| 3644 | providers: providerStatuses, | ||
| 3645 | hasStartedSession: activeThread.session !== null, | ||
| 3646 | currentModelSelection: activeThread.modelSelection, | ||
| 3647 | currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, | ||
| 3648 | nextModelSelection, | ||
| 3649 | }); | ||
| 3650 | if (modelChangeBlockReason) { | ||
| 3651 | toastManager.add({ | ||
| 3652 | type: "warning", | ||
| 3653 | title: modelChangeBlockReason.title, | ||
| 3654 | description: modelChangeBlockReason.description, | ||
| 3655 | }); | ||
| 3656 | scheduleComposerFocus(); | ||
| 3657 | return; | ||
| 3658 | } | ||
| 3636 | setComposerDraftModelSelection( | 3659 | setComposerDraftModelSelection( |
| 3637 | scopeThreadRef(activeThread.environmentId, activeThread.id), | 3660 | scopeThreadRef(activeThread.environmentId, activeThread.id), |
| 3638 | nextModelSelection, | 3661 | nextModelSelection, |
apps/web/src/components/Icons.tsx+12| ... | @@ -199,6 +199,18 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( | ... | @@ -199,6 +199,18 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( |
| 199 | </svg> | 199 | </svg> |
| 200 | ); | 200 | ); |
| 201 | 201 | ||
| 202 | export const GrokIcon: Icon = ({ className, ...props }) => ( | ||
| 203 | <svg | ||
| 204 | {...props} | ||
| 205 | viewBox="0 0 24 24" | ||
| 206 | fill="none" | ||
| 207 | className={cn("fill-[#0F0F0F] dark:fill-[#F5F5F5]", className)} | ||
| 208 | > | ||
| 209 | <path d="M9.26905 15.284L17.2479 9.36086C17.6391 9.07047 18.1981 9.18374 18.3845 9.63478C19.3655 12.0135 18.9272 14.8721 16.9755 16.8349C15.0238 18.7976 12.3082 19.228 9.8261 18.2477L7.1146 19.5102C11.0037 22.1834 15.7263 21.5223 18.6774 18.5525C21.0182 16.1985 21.7432 12.9897 21.0653 10.0961L21.0714 10.1023C20.0884 5.85143 21.3131 4.15233 23.8218 0.677913C23.8812 0.595532 23.9406 0.513151 24 0.428711L20.6987 3.74866V3.73836L9.267 15.2861" /> | ||
| 210 | <path d="M7.62249 16.7237C4.83113 14.0422 5.3124 9.89222 7.69417 7.49905C9.45541 5.72786 12.341 5.00497 14.86 6.06768L17.5653 4.81138C17.0779 4.45714 16.4533 4.07613 15.7365 3.80839C12.4966 2.46764 8.6178 3.13492 5.98413 5.78141C3.45081 8.32904 2.65415 12.2463 4.02219 15.5889C5.04412 18.0871 3.36889 19.8541 1.68137 21.6377C1.08337 22.2699 0.483318 22.9022 0 23.5716L7.62045 16.7257" /> | ||
| 211 | </svg> | ||
| 212 | ); | ||
| 213 | |||
| 202 | export const TraeIcon: Icon = (props) => ( | 214 | export const TraeIcon: Icon = (props) => ( |
| 203 | <svg {...props} viewBox="0 0 24 24" fill="currentColor"> | 215 | <svg {...props} viewBox="0 0 24 24" fill="currentColor"> |
| 204 | {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} | 216 | {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} |
apps/web/src/components/KeybindingsToast.browser.tsx+1| ... | @@ -158,6 +158,7 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -158,6 +158,7 @@ function createBaseServerConfig(): ServerConfig { |
| 158 | launchArgs: "", | 158 | launchArgs: "", |
| 159 | }, | 159 | }, |
| 160 | cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, | 160 | cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, |
| 161 | grok: { enabled: true, binaryPath: "", customModels: [] }, | ||
| 161 | opencode: { | 162 | opencode: { |
| 162 | enabled: true, | 163 | enabled: true, |
| 163 | binaryPath: "", | 164 | binaryPath: "", |
apps/web/src/components/chat/providerIconUtils.ts+2-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { ProviderDriverKind } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 2 | import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 3 | import { PROVIDER_OPTIONS } from "../../session-logic"; | 3 | import { PROVIDER_OPTIONS } from "../../session-logic"; |
| 4 | 4 | ||
| 5 | export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { | 5 | export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| ... | @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon> | ... | @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon> |
| 7 | [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, | 7 | [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, |
| 8 | [ProviderDriverKind.make("opencode")]: OpenCodeIcon, | 8 | [ProviderDriverKind.make("opencode")]: OpenCodeIcon, |
| 9 | [ProviderDriverKind.make("cursor")]: CursorIcon, | 9 | [ProviderDriverKind.make("cursor")]: CursorIcon, |
| 10 | [ProviderDriverKind.make("grok")]: GrokIcon, | ||
| 10 | }; | 11 | }; |
| 11 | 12 | ||
| 12 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { | 13 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { |
apps/web/src/components/settings/providerDriverMeta.ts+9-1| ... | @@ -2,11 +2,12 @@ import { | ... | @@ -2,11 +2,12 @@ import { |
| 2 | ClaudeSettings, | 2 | ClaudeSettings, |
| 3 | CodexSettings, | 3 | CodexSettings, |
| 4 | CursorSettings, | 4 | CursorSettings, |
| 5 | GrokSettings, | ||
| 5 | OpenCodeSettings, | 6 | OpenCodeSettings, |
| 6 | ProviderDriverKind, | 7 | ProviderDriverKind, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import type * as Schema from "effect/Schema"; | 9 | import type * as Schema from "effect/Schema"; |
| 9 | import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 10 | import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 10 | 11 | ||
| 11 | type ProviderSettingsSchema = { | 12 | type ProviderSettingsSchema = { |
| 12 | readonly fields: Readonly<Record<string, Schema.Top>>; | 13 | readonly fields: Readonly<Record<string, Schema.Top>>; |
| ... | @@ -53,6 +54,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = | ... | @@ -53,6 +54,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = |
| 53 | badgeLabel: "Early Access", | 54 | badgeLabel: "Early Access", |
| 54 | settingsSchema: CursorSettings, | 55 | settingsSchema: CursorSettings, |
| 55 | }, | 56 | }, |
| 57 | { | ||
| 58 | value: ProviderDriverKind.make("grok"), | ||
| 59 | label: "Grok", | ||
| 60 | icon: GrokIcon, | ||
| 61 | badgeLabel: "Early Access", | ||
| 62 | settingsSchema: GrokSettings, | ||
| 63 | }, | ||
| 56 | { | 64 | { |
| 57 | value: ProviderDriverKind.make("opencode"), | 65 | value: ProviderDriverKind.make("opencode"), |
| 58 | label: "OpenCode", | 66 | label: "OpenCode", |
apps/web/src/environments/runtime/catalog.test.ts+5-1| ... | @@ -15,6 +15,10 @@ import { | ... | @@ -15,6 +15,10 @@ import { |
| 15 | writeSavedEnvironmentCredential, | 15 | writeSavedEnvironmentCredential, |
| 16 | } from "./catalog"; | 16 | } from "./catalog"; |
| 17 | 17 | ||
| 18 | let resolveRegistryRead: () => void = () => { | ||
| 19 | throw new Error("Registry read resolver was not initialized."); | ||
| 20 | }; | ||
| 21 | |||
| 18 | describe("environment runtime catalog stores", () => { | 22 | describe("environment runtime catalog stores", () => { |
| 19 | beforeEach(async () => { | 23 | beforeEach(async () => { |
| 20 | vi.stubGlobal("window", { | 24 | vi.stubGlobal("window", { |
| ... | @@ -138,7 +142,7 @@ describe("environment runtime catalog stores", () => { | ... | @@ -138,7 +142,7 @@ describe("environment runtime catalog stores", () => { |
| 138 | }); | 142 | }); |
| 139 | 143 | ||
| 140 | it("does not let stale hydration overwrite records added while hydration is in flight", async () => { | 144 | it("does not let stale hydration overwrite records added while hydration is in flight", async () => { |
| 141 | let resolveRegistryRead: () => void = () => { | 145 | resolveRegistryRead = () => { |
| 142 | throw new Error("Registry read resolver was not initialized."); | 146 | throw new Error("Registry read resolver was not initialized."); |
| 143 | }; | 147 | }; |
| 144 | 148 |
apps/web/src/modelSelection.test.ts+21| ... | @@ -101,6 +101,27 @@ describe("instance-scoped model selection", () => { | ... | @@ -101,6 +101,27 @@ describe("instance-scoped model selection", () => { |
| 101 | ).toBe("openai/gpt-5.5"); | 101 | ).toBe("openai/gpt-5.5"); |
| 102 | }); | 102 | }); |
| 103 | 103 | ||
| 104 | it("includes Grok custom models from the selected provider instance", () => { | ||
| 105 | const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })]; | ||
| 106 | const settings: UnifiedSettings = { | ||
| 107 | ...settingsWithProviderInstances(), | ||
| 108 | providerInstances: { | ||
| 109 | ...settingsWithProviderInstances().providerInstances, | ||
| 110 | [ProviderInstanceId.make("grok")]: { | ||
| 111 | driver: ProviderDriverKind.make("grok"), | ||
| 112 | config: { customModels: ["grok-test-custom-model"] }, | ||
| 113 | }, | ||
| 114 | }, | ||
| 115 | }; | ||
| 116 | const grok = deriveProviderInstanceEntries(providers).find( | ||
| 117 | (entry) => entry.instanceId === "grok", | ||
| 118 | )!; | ||
| 119 | |||
| 120 | expect(getAppModelOptionsForInstance(settings, grok).map((option) => option.slug)).toContain( | ||
| 121 | "grok-test-custom-model", | ||
| 122 | ); | ||
| 123 | }); | ||
| 124 | |||
| 104 | it("does not inject an unknown selected slug into the stock instance list", () => { | 125 | it("does not inject an unknown selected slug into the stock instance list", () => { |
| 105 | const providers = [ | 126 | const providers = [ |
| 106 | provider({ | 127 | provider({ |
apps/web/src/session-logic.ts+6| ... | @@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{ | ... | @@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{ |
| 45 | available: true, | 45 | available: true, |
| 46 | pickerSidebarBadge: "new", | 46 | pickerSidebarBadge: "new", |
| 47 | }, | 47 | }, |
| 48 | { | ||
| 49 | value: ProviderDriverKind.make("grok"), | ||
| 50 | label: "Grok", | ||
| 51 | available: true, | ||
| 52 | pickerSidebarBadge: "new", | ||
| 53 | }, | ||
| 48 | ]; | 54 | ]; |
| 49 | 55 | ||
| 50 | export interface WorkLogEntry { | 56 | export interface WorkLogEntry { |
packages/contracts/src/model.ts+3| ... | @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; | ... | @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; |
| 130 | const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); | 130 | const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 131 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); | 131 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 132 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); | 132 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 133 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); | ||
| 133 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); | 134 | const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 134 | 135 | ||
| 135 | export const DEFAULT_MODEL = "gpt-5.4"; | 136 | export const DEFAULT_MODEL = "gpt-5.4"; |
| ... | @@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin | ... | @@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin |
| 139 | [CODEX_DRIVER_KIND]: DEFAULT_MODEL, | 140 | [CODEX_DRIVER_KIND]: DEFAULT_MODEL, |
| 140 | [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6", | 141 | [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6", |
| 141 | [CURSOR_DRIVER_KIND]: "auto", | 142 | [CURSOR_DRIVER_KIND]: "auto", |
| 143 | [GROK_DRIVER_KIND]: "grok-build", | ||
| 142 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", | 144 | [OPENCODE_DRIVER_KIND]: "openai/gpt-5", |
| 143 | }; | 145 | }; |
| 144 | 146 | ||
| ... | @@ -201,5 +203,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> | ... | @@ -201,5 +203,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> |
| 201 | [CODEX_DRIVER_KIND]: "Codex", | 203 | [CODEX_DRIVER_KIND]: "Codex", |
| 202 | [CLAUDE_DRIVER_KIND]: "Claude", | 204 | [CLAUDE_DRIVER_KIND]: "Claude", |
| 203 | [CURSOR_DRIVER_KIND]: "Cursor", | 205 | [CURSOR_DRIVER_KIND]: "Cursor", |
| 206 | [GROK_DRIVER_KIND]: "Grok", | ||
| 204 | [OPENCODE_DRIVER_KIND]: "OpenCode", | 207 | [OPENCODE_DRIVER_KIND]: "OpenCode", |
| 205 | }; | 208 | }; |
packages/contracts/src/server.ts+1| ... | @@ -165,6 +165,7 @@ export const ServerProvider = Schema.Struct({ | ... | @@ -165,6 +165,7 @@ export const ServerProvider = Schema.Struct({ |
| 165 | badgeLabel: Schema.optional(TrimmedNonEmptyString), | 165 | badgeLabel: Schema.optional(TrimmedNonEmptyString), |
| 166 | continuation: Schema.optional(ServerProviderContinuation), | 166 | continuation: Schema.optional(ServerProviderContinuation), |
| 167 | showInteractionModeToggle: Schema.optional(Schema.Boolean), | 167 | showInteractionModeToggle: Schema.optional(Schema.Boolean), |
| 168 | requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), | ||
| 168 | enabled: Schema.Boolean, | 169 | enabled: Schema.Boolean, |
| 169 | installed: Schema.Boolean, | 170 | installed: Schema.Boolean, |
| 170 | version: Schema.NullOr(TrimmedNonEmptyString), | 171 | version: Schema.NullOr(TrimmedNonEmptyString), |
packages/contracts/src/settings.ts+32| ... | @@ -281,6 +281,30 @@ export const CursorSettings = makeProviderSettingsSchema( | ... | @@ -281,6 +281,30 @@ export const CursorSettings = makeProviderSettingsSchema( |
| 281 | ); | 281 | ); |
| 282 | export type CursorSettings = typeof CursorSettings.Type; | 282 | export type CursorSettings = typeof CursorSettings.Type; |
| 283 | 283 | ||
| 284 | export const GrokSettings = makeProviderSettingsSchema( | ||
| 285 | { | ||
| 286 | enabled: Schema.Boolean.pipe( | ||
| 287 | Schema.withDecodingDefault(Effect.succeed(true)), | ||
| 288 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | ||
| 289 | ), | ||
| 290 | binaryPath: makeBinaryPathSetting("grok").pipe( | ||
| 291 | Schema.annotateKey({ | ||
| 292 | title: "Binary path", | ||
| 293 | description: "Path to the Grok CLI binary.", | ||
| 294 | providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, | ||
| 295 | }), | ||
| 296 | ), | ||
| 297 | customModels: Schema.Array(Schema.String).pipe( | ||
| 298 | Schema.withDecodingDefault(Effect.succeed([])), | ||
| 299 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | ||
| 300 | ), | ||
| 301 | }, | ||
| 302 | { | ||
| 303 | order: ["binaryPath"], | ||
| 304 | }, | ||
| 305 | ); | ||
| 306 | export type GrokSettings = typeof GrokSettings.Type; | ||
| 307 | |||
| 284 | export const OpenCodeSettings = makeProviderSettingsSchema( | 308 | export const OpenCodeSettings = makeProviderSettingsSchema( |
| 285 | { | 309 | { |
| 286 | enabled: Schema.Boolean.pipe( | 310 | enabled: Schema.Boolean.pipe( |
| ... | @@ -369,6 +393,7 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -369,6 +393,7 @@ export const ServerSettings = Schema.Struct({ |
| 369 | codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 393 | codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 370 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 394 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 371 | cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 395 | cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 396 | grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | ||
| 372 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 397 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 373 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 398 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 374 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values | 399 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values |
| ... | @@ -437,6 +462,12 @@ const CursorSettingsPatch = Schema.Struct({ | ... | @@ -437,6 +462,12 @@ const CursorSettingsPatch = Schema.Struct({ |
| 437 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 462 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 438 | }); | 463 | }); |
| 439 | 464 | ||
| 465 | const GrokSettingsPatch = Schema.Struct({ | ||
| 466 | enabled: Schema.optionalKey(Schema.Boolean), | ||
| 467 | binaryPath: Schema.optionalKey(TrimmedString), | ||
| 468 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | ||
| 469 | }); | ||
| 470 | |||
| 440 | const OpenCodeSettingsPatch = Schema.Struct({ | 471 | const OpenCodeSettingsPatch = Schema.Struct({ |
| 441 | enabled: Schema.optionalKey(Schema.Boolean), | 472 | enabled: Schema.optionalKey(Schema.Boolean), |
| 442 | binaryPath: Schema.optionalKey(TrimmedString), | 473 | binaryPath: Schema.optionalKey(TrimmedString), |
| ... | @@ -463,6 +494,7 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -463,6 +494,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 463 | codex: Schema.optionalKey(CodexSettingsPatch), | 494 | codex: Schema.optionalKey(CodexSettingsPatch), |
| 464 | claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), | 495 | claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), |
| 465 | cursor: Schema.optionalKey(CursorSettingsPatch), | 496 | cursor: Schema.optionalKey(CursorSettingsPatch), |
| 497 | grok: Schema.optionalKey(GrokSettingsPatch), | ||
| 466 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), | 498 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), |
| 467 | }), | 499 | }), |
| 468 | ), | 500 | ), |
packages/shared/src/model.test.ts+3| ... | @@ -94,6 +94,9 @@ describe("resolveModelSlugForProvider", () => { | ... | @@ -94,6 +94,9 @@ describe("resolveModelSlugForProvider", () => { |
| 94 | expect(resolveModelSlugForProvider(ProviderDriverKind.make("ollama"), undefined)).toBe( | 94 | expect(resolveModelSlugForProvider(ProviderDriverKind.make("ollama"), undefined)).toBe( |
| 95 | DEFAULT_MODEL, | 95 | DEFAULT_MODEL, |
| 96 | ); | 96 | ); |
| 97 | expect(resolveModelSlugForProvider(ProviderDriverKind.make("grok"), undefined)).toBe( | ||
| 98 | "grok-build", | ||
| 99 | ); | ||
| 97 | }); | 100 | }); |
| 98 | 101 | ||
| 99 | it("preserves normalized unknown models", () => { | 102 | it("preserves normalized unknown models", () => { |