Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/server/src/provider/Drivers/GrokDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
const { cwd } = yield* ServerConfig;
const processEnv = mergeProviderInstanceEnvironment(environment);
const continuationIdentity = defaultProviderContinuationIdentity({
driverKind: DRIVER_KIND,
Expand All @@ -113,7 +114,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
});
const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);

const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe(
const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe(
Effect.map(stampIdentity),
Effect.provideService(Crypto.Crypto, crypto),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/provider/Layers/GrokProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,36 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
expect(snapshot.message).toContain("ACP startup failed");
}),
);

it.effect("accepts an explicit workspace cwd for ACP discovery without hanging probes", () =>
Effect.gen(function* () {
// Regression: probe must accept ServerConfig.cwd (not only process.cwd()).
// Discovery still fails with a version-only binary; the important part is
// the third argument is plumbed and check completes.
const snapshot = yield* Effect.scoped(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-workspace-cwd-" });
const workspaceCwd = path.join(dir, "workspace");
yield* fs.makeDirectory(workspaceCwd, { recursive: true });
const grokPath = path.join(dir, "grok");
yield* fs.writeFileString(
grokPath,
["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"),
);
yield* fs.chmod(grokPath, 0o755);

return yield* checkGrokProviderStatus(
decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
process.env,
workspaceCwd,
);
}),
);

expect(snapshot.status).toBe("error");
expect(snapshot.message).toContain("ACP startup failed");
}),
);
});
138 changes: 132 additions & 6 deletions apps/server/src/provider/Layers/GrokProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import {
ProviderDriverKind,
type ServerProvider,
type ServerProviderModel,
type ServerProviderSlashCommand,
} from "@t3tools/contracts";
import type * as EffectAcpSchema from "effect-acp/schema";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as Clock from "effect/Clock";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Result from "effect/Result";
import { HttpClient } from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
Expand All @@ -31,6 +35,7 @@ import {
type ProviderMaintenanceCapabilities,
} from "../providerMaintenance.ts";
import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts";
import { parseAcpAvailableCommands } from "../acp/parseAcpAvailableCommands.ts";

const GROK_PRESENTATION = {
displayName: "Grok",
Expand All @@ -45,6 +50,24 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({

const VERSION_PROBE_TIMEOUT_MS = 4_000;
const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
/**
* Headroom so slash-command wait + returning from discovery finishes before the
* outer Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS) fires.
*/
const GROK_ACP_DISCOVERY_TIMEOUT_RESERVE_MS = 200;
/** Ideal max wait for available_commands_update after session/new (may be capped). */
const GROK_ACP_SLASH_COMMAND_WAIT_MS = 2_000;
/**
* After an empty update, briefly watch for a follow-up non-empty "changed"
* update without burning the full wait budget.
*/
const GROK_ACP_SLASH_EMPTY_SETTLE_MS = 250;
const GROK_ACP_SLASH_COMMAND_POLL_MS = 50;

interface GrokAcpDiscoveryResult {
readonly models: ReadonlyArray<ServerProviderModel>;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
}

const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
Expand Down Expand Up @@ -130,21 +153,118 @@ function buildGrokDiscoveredModelsFromSessionModelState(
.filter((model): model is ServerProviderModel => model !== undefined);
}

/**
* Wait for ACP `available_commands_update` within `maxWaitMs` (remaining discovery budget).
*
* - Option.none = no notification yet → wait up to maxWaitMs
* - Option.some([]) = empty → wait min(empty settle, maxWaitMs) for a non-empty follow-up
* - Option.some(nonEmpty) = return immediately
* - maxWaitMs <= 0 = return whatever is already on the ref (slash wait is non-fatal)
*/
const waitForGrokSlashCommands = (
slashCommandsRef: Ref.Ref<Option.Option<ReadonlyArray<ServerProviderSlashCommand>>>,
maxWaitMs: number,
) =>
Effect.gen(function* () {
const readCommands = Effect.map(Ref.get(slashCommandsRef), (commandsOpt) =>
Option.isSome(commandsOpt) ? commandsOpt.value : [],
);

if (maxWaitMs <= 0) {
return yield* readCommands;
}

const startedAt = yield* Clock.currentTimeMillis;
const emptySettleMs = Math.min(GROK_ACP_SLASH_EMPTY_SETTLE_MS, maxWaitMs);
let emptySeenAt: number | undefined;

while (true) {
const now = yield* Clock.currentTimeMillis;
const elapsed = now - startedAt;
const commandsOpt = yield* Ref.get(slashCommandsRef);

if (Option.isSome(commandsOpt) && commandsOpt.value.length > 0) {
return commandsOpt.value;
}

if (Option.isSome(commandsOpt) && commandsOpt.value.length === 0) {
emptySeenAt ??= now;
if (now - emptySeenAt >= emptySettleMs) {
return commandsOpt.value;
}
}

const remaining = maxWaitMs - elapsed;
if (remaining <= 0) {
return Option.isSome(commandsOpt) ? commandsOpt.value : [];
}

yield* Effect.sleep(Duration.millis(Math.min(GROK_ACP_SLASH_COMMAND_POLL_MS, remaining)));
}
});

const discoverGrokModelsViaAcp = (
grokSettings: GrokSettings,
environment: NodeJS.ProcessEnv = process.env,
/**
* Workspace root for `session/new`. Must match live Grok threads so project-local
* skills (`.agents/skills`, `.grok/skills`, etc.) match the composer menu.
*/
cwd: string = process.cwd(),
) =>
Effect.gen(function* () {
// Wall clock for the whole discovery effect so slash wait cannot overrun the
// outer GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS and discard already-found models.
const discoveryStartedAt = yield* Clock.currentTimeMillis;
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const acp = yield* makeGrokAcpRuntime({
grokSettings,
environment,
childProcessSpawner,
cwd: process.cwd(),
cwd,
clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
});

// Collect ACP available_commands_update (skills + builtins) for the composer.
// Register before start so we do not miss the post-session/new notification.
// Option.none = not yet received; Option.some(list) = notification seen (list may be empty).
const slashCommandsRef = yield* Ref.make(
Option.none<ReadonlyArray<ServerProviderSlashCommand>>(),
);
yield* acp.handleSessionUpdate((notification) => {
const update = notification.update;
if (update.sessionUpdate !== "available_commands_update") {
return Effect.void;
}
return Ref.set(
slashCommandsRef,
Option.some(parseAcpAvailableCommands(update.availableCommands)),
);
});

const started = yield* acp.start();
return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models);
const models = buildGrokDiscoveredModelsFromSessionModelState(
started.sessionSetupResult.models,
);

// Slash discovery is best-effort: cap wait by remaining outer budget so a slow
// acp.start() never turns a successful model discovery into a timeout failure.
const now = yield* Clock.currentTimeMillis;
const remainingDiscoveryMs = Math.max(
0,
GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS -
(now - discoveryStartedAt) -
GROK_ACP_DISCOVERY_TIMEOUT_RESERVE_MS,
);
const slashWaitMs = Math.min(GROK_ACP_SLASH_COMMAND_WAIT_MS, remainingDiscoveryMs);

const slashCommandsOpt = yield* Ref.get(slashCommandsRef);
const slashCommands =
Option.isSome(slashCommandsOpt) && slashCommandsOpt.value.length > 0
? slashCommandsOpt.value
: yield* waitForGrokSlashCommands(slashCommandsRef, slashWaitMs);

return { models, slashCommands } satisfies GrokAcpDiscoveryResult;
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}).pipe(Effect.scoped);

const runGrokVersionCommand = (
Expand All @@ -168,6 +288,11 @@ const runGrokVersionCommand = (
export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
grokSettings: GrokSettings,
environment: NodeJS.ProcessEnv = process.env,
/**
* Server workspace cwd from `ServerConfig` (not `process.cwd()`). Used for ACP
* skill/command discovery so the slash menu matches live Grok threads.
*/
cwd: string = process.cwd(),
): Effect.fn.Return<
ServerProviderDraft,
never,
Expand Down Expand Up @@ -258,7 +383,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
});
}

const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(
const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment, cwd).pipe(
Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
Effect.exit,
);
Expand Down Expand Up @@ -298,17 +423,18 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
},
});
}
const discoveredModels = discoveryExit.value.value;
const discovery = discoveryExit.value.value;
const models =
discoveredModels.length > 0
? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
discovery.models.length > 0
? grokModelsFromSettings(grokSettings.customModels, discovery.models)
: fallbackModels;

return buildServerProvider({
presentation: GROK_PRESENTATION,
enabled: grokSettings.enabled,
checkedAt,
models,
slashCommands: discovery.slashCommands,
probe: {
installed: true,
version,
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/provider/acp/parseAcpAvailableCommands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "@effect/vitest";

import { parseAcpAvailableCommands } from "./parseAcpAvailableCommands.ts";

describe("parseAcpAvailableCommands", () => {
it("maps ACP available commands into provider slash commands", () => {
expect(
parseAcpAvailableCommands([
{
name: "factory-planner",
description: "Plan factory tickets",
input: { hint: "ticket intent" },
},
{
name: "context",
description: "Show context usage",
},
]),
).toEqual([
{
name: "factory-planner",
description: "Plan factory tickets",
input: { hint: "ticket intent" },
},
{
name: "context",
description: "Show context usage",
},
]);
});

it("dedupes by case-insensitive name and keeps the first description/hint", () => {
expect(
parseAcpAvailableCommands([
{ name: "Trade-Research", description: "first", input: { hint: "ticker" } },
{ name: "trade-research", description: "second", input: { hint: "other" } },
{ name: " ", description: "ignored" },
]),
).toEqual([
{
name: "Trade-Research",
description: "first",
input: { hint: "ticker" },
},
]);
});

it("returns an empty list for nullish input", () => {
expect(parseAcpAvailableCommands(undefined)).toEqual([]);
expect(parseAcpAvailableCommands(null)).toEqual([]);
expect(parseAcpAvailableCommands([])).toEqual([]);
});
});
46 changes: 46 additions & 0 deletions apps/server/src/provider/acp/parseAcpAvailableCommands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ServerProviderSlashCommand } from "@t3tools/contracts";
import type * as EffectAcpSchema from "effect-acp/schema";

import { nonEmptyTrimmed } from "../providerSnapshot.ts";

/**
* Map ACP `available_commands_update` entries into the provider slash-command
* shape used by the T3 composer (same contract Claude fills from SDK init).
*/
export function parseAcpAvailableCommands(
commands: ReadonlyArray<EffectAcpSchema.AvailableCommand> | null | undefined,
): ReadonlyArray<ServerProviderSlashCommand> {
const byName = new Map<string, ServerProviderSlashCommand>();

for (const command of commands ?? []) {
const name = nonEmptyTrimmed(command.name);
if (!name) {
continue;
}

const description = nonEmptyTrimmed(command.description);
const hint =
command.input && typeof command.input === "object" && "hint" in command.input
? nonEmptyTrimmed(command.input.hint)
: undefined;

const key = name.toLowerCase();
const existing = byName.get(key);
if (!existing) {
byName.set(key, {
name,
...(description ? { description } : {}),
...(hint ? { input: { hint } } : {}),
});
continue;
}

byName.set(key, {
...existing,
...(existing.description ? {} : description ? { description } : {}),
...(existing.input?.hint ? {} : hint ? { input: { hint } } : {}),
});
}

return [...byName.values()];
}
Loading
Loading