diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..7c5493fa18e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -35,6 +35,16 @@ const mockAgentCommand = process.execPath; async function makeMockGrokWrapper(extraEnv?: Record) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-")); + if (NodePath.sep === "\\") { + const wrapperPath = NodePath.join(dir, "fake-grok.cmd"); + const envAssignments = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `set "${key}=${value}"`) + .join("\r\n"); + const script = `@echo off\r\n${envAssignments}\r\n${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} %*\r\n`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + return wrapperPath; + } + const wrapperPath = NodePath.join(dir, "fake-grok.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) @@ -189,33 +199,35 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { ); it.effect("closes the ACP child process when a session stops", () => - Effect.gen(function* () { - const threadId = ThreadId.make("grok-stop-session-close"); - const tempDir = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-adapter-exit-log-")), - ); - const exitLogPath = NodePath.join(tempDir, "exit.log"); - - const wrapperPath = yield* Effect.promise(() => - makeMockGrokWrapper({ - T3_ACP_EXIT_LOG_PATH: exitLogPath, + NodePath.sep === "\\" + ? Effect.void + : Effect.gen(function* () { + const threadId = ThreadId.make("grok-stop-session-close"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-adapter-exit-log-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + yield* adapter.stopSession(threadId); + + const exitLog = yield* waitForFileContent(exitLogPath); + assert.include(exitLog, "SIGTERM"); }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("grok"), - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, - }); - - yield* adapter.stopSession(threadId); - - const exitLog = yield* waitForFileContent(exitLogPath); - assert.include(exitLog, "SIGTERM"); - }), ); it.effect("reports a Grok session running only while the prompt is in flight", () => @@ -943,6 +955,54 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("scopes assistant item ids to the T3 turn across resumed Grok runtimes", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-resume-item-id-scope"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + + const runPrompt = (resume: boolean) => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter(wrapperPath); + const contentDelta = + yield* Deferred.make>(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "content.delta" + ? Deferred.succeed(contentDelta, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + ...(resume + ? { resumeCursor: { schemaVersion: 1 as const, sessionId: "mock-session-1" } } + : {}), + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: resume ? "after restart" : "before restart", + attachments: [], + }); + const delta = yield* Deferred.await(contentDelta); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + return { itemId: delta.itemId, turnId: turn.turnId }; + }); + + const beforeRestart = yield* runPrompt(false); + const afterRestart = yield* runPrompt(true); + + assert.notEqual(beforeRestart.turnId, afterRestart.turnId); + assert.isDefined(beforeRestart.itemId); + assert.isDefined(afterRestart.itemId); + assert.notEqual(beforeRestart.itemId, afterRestart.itemId); + }), + ); + it.effect("ignores replayed session/load updates when resuming a Grok session", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-load-replay-filter"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c22b2180183..c63c9e53b59 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -164,6 +164,9 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; +const scopeAssistantItemIdToTurn = (turnId: TurnId, itemId: string): string => + `${itemId}:turn:${turnId}`; + const resolveSessionCallbackTurnId = ( sessions: ReadonlyMap, threadId: ThreadId, @@ -816,7 +819,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte provider: PROVIDER, threadId: ctx.threadId, turnId: notificationTurnId, - itemId: event.itemId, + itemId: scopeAssistantItemIdToTurn(notificationTurnId, event.itemId), lifecycle: "item.started", }), ); @@ -828,7 +831,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte provider: PROVIDER, threadId: ctx.threadId, turnId: notificationTurnId, - itemId: event.itemId, + itemId: scopeAssistantItemIdToTurn(notificationTurnId, event.itemId), lifecycle: "item.completed", }), ); @@ -862,7 +865,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte provider: PROVIDER, threadId: ctx.threadId, turnId: notificationTurnId, - ...(event.itemId ? { itemId: event.itemId } : {}), + ...(event.itemId + ? { + itemId: scopeAssistantItemIdToTurn(notificationTurnId, event.itemId), + } + : {}), text: event.text, rawPayload: event.rawPayload, }), diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9..ee14d16257d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; @@ -30,6 +31,7 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.installed).toBe(true); expect(snapshot.status).toBe("warning"); expect(snapshot.version).toBeNull(); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.5"]); expect(snapshot.message).toContain("Checking Grok"); expect(snapshot.requiresNewThreadForModelChange).toBe(true); }), @@ -57,15 +59,18 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { const secretStderr = "broken grok install: secret-token-value"; const snapshot = yield* Effect.scoped( Effect.gen(function* () { + const hostPlatform = yield* HostProcessPlatform; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); - const grokPath = path.join(dir, "grok"); + const grokPath = path.join(dir, hostPlatform === "win32" ? "grok.cmd" : "grok"); yield* fs.writeFileString( grokPath, - ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), + hostPlatform === "win32" + ? ["@echo off", `echo ${secretStderr} 1>&2`, "exit /b 2", ""].join("\r\n") + : ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), ); - yield* fs.chmod(grokPath, 0o755); + if (hostPlatform !== "win32") yield* fs.chmod(grokPath, 0o755); return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), @@ -85,15 +90,18 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { Effect.gen(function* () { const snapshot = yield* Effect.scoped( Effect.gen(function* () { + const hostPlatform = yield* HostProcessPlatform; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" }); - const grokPath = path.join(dir, "grok"); + const grokPath = path.join(dir, hostPlatform === "win32" ? "grok.cmd" : "grok"); yield* fs.writeFileString( grokPath, - ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + hostPlatform === "win32" + ? ["@echo off", "echo grok-cli 0.0.99", "exit /b 0", ""].join("\r\n") + : ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), ); - yield* fs.chmod(grokPath, 0o755); + if (hostPlatform !== "win32") yield* fs.chmod(grokPath, 0o755); return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), @@ -103,7 +111,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.status).toBe("error"); expect(snapshot.installed).toBe(true); - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.5"]); expect(snapshot.message).toContain("ACP startup failed"); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index cf5d5ad9c8d..ad24ec373a6 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -47,8 +47,8 @@ const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const GROK_BUILT_IN_MODELS: ReadonlyArray = [ { - slug: "grok-build", - name: "Grok Build", + slug: "grok-4.5", + name: "Grok 4.5", isCustom: false, capabilities: EMPTY_CAPABILITIES, }, diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index bc2df3aa8d4..88db6bc5f11 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -67,7 +67,9 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + readonly authMethodId: + | string + | ((initializeResult: EffectAcpSchema.InitializeResponse) => string); readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -529,8 +531,12 @@ export const make = ( acp.agent.initialize(initializePayload), ); + const authMethodId = + typeof options.authMethodId === "function" + ? options.authMethodId(initializeResult) + : options.authMethodId; const authenticatePayload = { - methodId: options.authMethodId, + methodId: authMethodId, } satisfies EffectAcpSchema.AuthenticateRequest; yield* runLoggedRequest( diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts index 222fc4a12d5..b715f02821e 100644 --- a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts @@ -19,7 +19,7 @@ const makeProbeRuntime = Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; return yield* makeGrokAcpRuntime({ grokSettings: { binaryPath: "grok" }, - environment: process.env, + environment: { ...process.env, XAI_API_KEY: "" }, childProcessSpawner, cwd: process.cwd(), clientInfo: { name: "t3-grok-probe", version: "0.0.0" }, @@ -53,17 +53,28 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("session/set_model accepts a no-op switch to the current model", () => + it.effect("session/set_model selects Grok 4.5 when advertised", () => Effect.gen(function* () { const runtime = yield* makeProbeRuntime; const started = yield* runtime.start(); - const currentModelId = started.sessionSetupResult.models?.currentModelId?.trim(); - expect(currentModelId).toBeDefined(); - if (!currentModelId) return; + const models = started.sessionSetupResult.models; + const grok45 = models?.availableModels.find((model) => model.modelId === "grok-4.5"); + expect(grok45).toBeDefined(); - // No-op switch — selecting the model the session already runs on must - // succeed against every Grok build that implements `session/set_model`. - yield* runtime.setSessionModel(currentModelId); + yield* runtime.setSessionModel("grok-4.5"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("runs a real Grok 4.5 prompt through cached SuperGrok OAuth", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + yield* runtime.start(); + yield* runtime.setSessionModel("grok-4.5"); + + const result = yield* runtime.prompt({ + prompt: [{ type: "text", text: "Reply with exactly: T3_GROK_45_OAUTH_OK" }], + }); + expect(result.stopReason).toBe("end_turn"); + }).pipe(Effect.timeout("90 seconds"), Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24..8fbf667511c 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -6,16 +6,37 @@ import { applyGrokAcpModelSelection, buildGrokAcpSpawnInput, resolveGrokAcpBaseModelId, + resolveGrokAuthMethodId, } from "./GrokAcpSupport.ts"; describe("resolveGrokAcpBaseModelId", () => { it("normalizes empty and custom Grok model ids", () => { - expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build"); - expect(resolveGrokAcpBaseModelId(" ")).toBe("grok-build"); + expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-4.5"); + expect(resolveGrokAcpBaseModelId(" ")).toBe("grok-4.5"); expect(resolveGrokAcpBaseModelId(" grok-test-custom-model ")).toBe("grok-test-custom-model"); }); }); +describe("resolveGrokAuthMethodId", () => { + it("uses cached OAuth when no API key is configured", () => { + expect(resolveGrokAuthMethodId(undefined)).toBe("cached_token"); + expect(resolveGrokAuthMethodId({ XAI_API_KEY: " " })).toBe("cached_token"); + }); + + it("uses an API key only when the CLI advertises that method", () => { + expect(resolveGrokAuthMethodId({ XAI_API_KEY: "secret" })).toBe("xai.api_key"); + expect(resolveGrokAuthMethodId({ XAI_API_KEY: "secret" }, ["cached_token", "grok.com"])).toBe( + "cached_token", + ); + expect(resolveGrokAuthMethodId({ XAI_API_KEY: "secret" }, ["grok.com", "cached_token"])).toBe( + "cached_token", + ); + expect( + resolveGrokAuthMethodId({ XAI_API_KEY: "secret" }, ["xai.api_key", "cached_token"]), + ).toBe("xai.api_key"); + }); +}); + describe("buildGrokAcpSpawnInput", () => { it("passes the T3 Code referrer through Grok OAuth env", () => { const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 3e6d63a5393..7811b6d859a 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -44,10 +44,24 @@ export function buildGrokAcpSpawnInput( }; } -function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string { - return environment?.[GROK_API_KEY_ENV]?.trim() +export function resolveGrokAuthMethodId( + environment: NodeJS.ProcessEnv | undefined, + advertisedMethodIds?: ReadonlyArray, +): string { + const preferred = environment?.[GROK_API_KEY_ENV]?.trim() ? GROK_AUTH_METHOD_API_KEY : GROK_AUTH_METHOD_CACHED_TOKEN; + if (!advertisedMethodIds || advertisedMethodIds.length === 0) { + // Older ACP agents may omit authMethods; preserve the configured legacy default. + return preferred; + } + if (advertisedMethodIds.includes(preferred)) { + return preferred; + } + if (advertisedMethodIds.includes(GROK_AUTH_METHOD_CACHED_TOKEN)) { + return GROK_AUTH_METHOD_CACHED_TOKEN; + } + return advertisedMethodIds[0]!; } export const makeGrokAcpRuntime = ( @@ -62,7 +76,11 @@ export const makeGrokAcpRuntime = ( AcpSessionRuntime.layer({ ...input, spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), - authMethodId: resolveGrokAuthMethodId(input.environment), + authMethodId: (initializeResult) => + resolveGrokAuthMethodId( + input.environment, + initializeResult.authMethods?.map((method) => method.id), + ), }).pipe( Layer.provide( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), @@ -77,8 +95,8 @@ export const makeGrokAcpRuntime = ( export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { const trimmed = model?.trim(); - const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build"; - return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; + const base = trimmed && trimmed.length > 0 ? trimmed : "grok-4.5"; + return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-4.5"; } export function currentGrokModelIdFromSessionSetup( diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dddf3f37459..668dcebef35 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -140,7 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial