Skip to content
Closed
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
54 changes: 54 additions & 0 deletions apps/server/src/provider/Layers/GrokAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,60 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
}),
);

it.effect("does not set reasoning effort while steering an active prompt", () =>
Effect.gen(function* () {
const threadId = ThreadId.make("grok-steer-keeps-active-effort");
const tempDir = yield* Effect.promise(() =>
NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-steer-effort-")),
);
const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
const wrapperPath = yield* Effect.promise(() =>
makeMockGrokWrapper({
T3_ACP_PROMPT_DELAY_MS: "250",
T3_ACP_REQUEST_LOG_PATH: requestLogPath,
}),
);
const adapter = yield* makeTestAdapter(wrapperPath);
const modelSelection = {
instanceId: ProviderInstanceId.make("grok"),
model: "grok-build",
options: [{ id: "reasoningEffort", value: "high" }],
};

yield* adapter.startSession({
threadId,
provider: ProviderDriverKind.make("grok"),
cwd: process.cwd(),
runtimeMode: "full-access",
});

const firstPromptFiber = yield* adapter
.sendTurn({
threadId,
input: "start long-running work",
attachments: [],
modelSelection,
})
.pipe(Effect.forkChild);
yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"');

yield* adapter.sendTurn({
threadId,
input: "steer the active work",
attachments: [],
modelSelection,
});
yield* Fiber.join(firstPromptFiber);

const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
yield* adapter.stopSession(threadId);
assert.lengthOf(
requests.filter((request) => request.method === "session/set_model"),
1,
);
}).pipe(TestClock.withLive),
);

it.effect("restores ready without completing an unstarted turn when preparation fails", () =>
Effect.gen(function* () {
const threadId = ThreadId.make("grok-preparation-failure-while-connecting");
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/provider/Layers/GrokAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
runtime: acp,
currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
requestedModelId: requestedStartModelId,
selections: grokModelSelection?.options,
mapError: (cause) =>
mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
});
Expand Down Expand Up @@ -948,10 +949,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
const requestedTurnModelId = turnModelSelection?.model
? resolveGrokAcpBaseModelId(turnModelSelection.model)
: undefined;
// Grok cannot apply effort through set_model while an existing prompt is active.
const turnOptionsToApply =
steeringTurnId === undefined ? turnModelSelection?.options : undefined;
const currentModelId = yield* applyGrokAcpModelSelection({
runtime: ctx.acp,
currentModelId: ctx.currentModelId,
requestedModelId: requestedTurnModelId,
selections: turnOptionsToApply,
mapError: (cause) =>
mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
});
Expand Down
103 changes: 102 additions & 1 deletion apps/server/src/provider/Layers/GrokProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,111 @@ import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { GrokSettings } from "@t3tools/contracts";

import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts";
import {
buildGrokCapabilitiesFromModelMeta,
buildGrokDiscoveredModelsFromSessionModelState,
buildInitialGrokProviderSnapshot,
checkGrokProviderStatus,
} from "./GrokProvider.ts";

const decodeGrokSettings = Schema.decodeSync(GrokSettings);

describe("buildGrokCapabilitiesFromModelMeta", () => {
it("maps reasoningEfforts meta into a Reasoning select descriptor", () => {
// Opaque fixture values — not coupled to any live Grok catalog/menu.
const caps = buildGrokCapabilitiesFromModelMeta({
supportsReasoningEffort: true,
reasoningEffort: "effort-b",
reasoningEfforts: [
{ id: "effort-a", value: "effort-a", label: "Effort A", default: true },
{ id: "effort-b", value: "effort-b", label: "Effort B", default: false },
{ id: "effort-c", value: "effort-c", label: "Effort C", default: false },
],
});

expect(caps.optionDescriptors).toEqual([
{
id: "reasoningEffort",
label: "Reasoning",
type: "select",
currentValue: "effort-b",
options: [
{ id: "effort-a", label: "Effort A" },
{ id: "effort-b", label: "Effort B", isDefault: true },
{ id: "effort-c", label: "Effort C" },
],
},
]);
});

it("keeps menus independent per model meta", () => {
const caps = buildGrokCapabilitiesFromModelMeta({
supportsReasoningEffort: true,
reasoningEffort: "only",
reasoningEfforts: [{ id: "only", value: "only", label: "Only", default: false }],
});
expect(caps.optionDescriptors?.[0]?.type).toBe("select");
if (caps.optionDescriptors?.[0]?.type === "select") {
expect(caps.optionDescriptors[0].options.map((option) => option.id)).toEqual(["only"]);
expect(caps.optionDescriptors[0].currentValue).toBe("only");
}
});

it("returns empty capabilities when meta has no efforts", () => {
expect(buildGrokCapabilitiesFromModelMeta(undefined).optionDescriptors).toEqual([]);
expect(buildGrokCapabilitiesFromModelMeta({}).optionDescriptors).toEqual([]);
expect(
buildGrokCapabilitiesFromModelMeta({ supportsReasoningEffort: true }).optionDescriptors,
).toEqual([]);
});
});

describe("buildGrokDiscoveredModelsFromSessionModelState", () => {
it("attaches per-model capabilities from each entry's _meta", () => {
// Pure fixtures — slugs/menus are invented so catalog renames cannot break this.
const models = buildGrokDiscoveredModelsFromSessionModelState({
currentModelId: "model-a",
availableModels: [
{
modelId: "model-a",
name: "Model A",
_meta: {
reasoningEffort: "effort-b",
reasoningEfforts: [
{ value: "effort-a", label: "Effort A", default: true },
{ value: "effort-b", label: "Effort B", default: false },
{ value: "effort-c", label: "Effort C", default: false },
],
},
},
{
modelId: "model-b",
name: "Model B",
_meta: {
reasoningEffort: "only",
reasoningEfforts: [{ value: "only", label: "Only" }],
},
},
],
});

expect(models.map((model) => model.slug)).toEqual(["model-a", "model-b"]);
const modelADescriptor = models[0]?.capabilities?.optionDescriptors?.[0];
const modelBDescriptor = models[1]?.capabilities?.optionDescriptors?.[0];
expect(modelADescriptor).toMatchObject({
id: "reasoningEffort",
currentValue: "effort-b",
});
expect(modelBDescriptor).toMatchObject({
id: "reasoningEffort",
currentValue: "only",
});
if (modelBDescriptor?.type === "select") {
expect(modelBDescriptor.options.map((option) => option.id)).toEqual(["only"]);
}
});
});

describe("buildInitialGrokProviderSnapshot", () => {
it.effect("returns a disabled snapshot when settings.enabled is false", () =>
Effect.gen(function* () {
Expand Down
79 changes: 76 additions & 3 deletions apps/server/src/provider/Layers/GrokProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";
import { resolveSpawnCommand } from "@t3tools/shared/shell";

import {
buildSelectOptionDescriptor,
buildServerProvider,
isCommandMissingCause,
parseGenericCliVersion,
Expand All @@ -29,7 +30,11 @@ import {
enrichProviderSnapshotWithVersionAdvisory,
type ProviderMaintenanceCapabilities,
} from "../providerMaintenance.ts";
import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts";
import {
GROK_REASONING_EFFORT_OPTION_ID,
makeGrokAcpRuntime,
resolveGrokAcpBaseModelId,
} from "../acp/GrokAcpSupport.ts";

const GROK_PRESENTATION = {
displayName: "Grok",
Expand All @@ -53,6 +58,74 @@ const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
},
];

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Build model capabilities from Grok ACP model `_meta`.
* Effort menus are per-model (built-in and custom) via `reasoningEfforts`.
*/
export function buildGrokCapabilitiesFromModelMeta(meta: unknown): ModelCapabilities {
if (!isRecord(meta)) {
return EMPTY_CAPABILITIES;
}

const effortsRaw = meta.reasoningEfforts;
if (!Array.isArray(effortsRaw) || effortsRaw.length === 0) {
return EMPTY_CAPABILITIES;
}

const currentEffort =
typeof meta.reasoningEffort === "string" ? meta.reasoningEffort.trim() : undefined;

const parsed = effortsRaw.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const valueRaw =
typeof entry.value === "string" ? entry.value : typeof entry.id === "string" ? entry.id : "";
const value = valueRaw.trim();
if (!value) {
return [];
}
const label =
typeof entry.label === "string" && entry.label.trim().length > 0 ? entry.label.trim() : value;
return [
{
value,
label,
markedDefault: entry.default === true,
},
];
});

if (parsed.length === 0) {
return EMPTY_CAPABILITIES;
}

const preferredDefault =
(currentEffort && parsed.some((option) => option.value === currentEffort)
? currentEffort
: undefined) ??
parsed.find((option) => option.markedDefault)?.value ??
parsed[0]?.value;

return createModelCapabilities({
optionDescriptors: [
buildSelectOptionDescriptor({
id: GROK_REASONING_EFFORT_OPTION_ID,
label: "Reasoning",
options: parsed.map((option) => ({
value: option.value,
label: option.label,
...(option.value === preferredDefault ? { isDefault: true as const } : {}),
})),
}),
],
});
}

export function buildInitialGrokProviderSnapshot(
grokSettings: GrokSettings,
): Effect.Effect<ServerProviderDraft> {
Expand Down Expand Up @@ -99,7 +172,7 @@ function grokModelsFromSettings(
return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
}

function buildGrokDiscoveredModelsFromSessionModelState(
export function buildGrokDiscoveredModelsFromSessionModelState(
modelState: EffectAcpSchema.SessionModelState | null | undefined,
): ReadonlyArray<ServerProviderModel> {
if (!modelState || modelState.availableModels.length === 0) {
Expand All @@ -117,7 +190,7 @@ function buildGrokDiscoveredModelsFromSessionModelState(
slug,
name: model.name.trim() || slug,
isCustom: false,
capabilities: EMPTY_CAPABILITIES,
capabilities: buildGrokCapabilitiesFromModelMeta(model._meta),
};
})
.filter((model): model is ServerProviderModel => model !== undefined);
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/provider/acp/AcpSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,10 +222,14 @@ export class AcpSessionRuntime extends Context.Service<
readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
/**
* Selects the active model through the unstable ACP `session/set_model` capability.
* Optional `_meta` is passed through for agent extensions (e.g. Grok `reasoningEffort`).
* @see https://agentclientprotocol.com/protocol/schema#session/set_model
*/
readonly setSessionModel: (
modelId: string,
options?: {
readonly _meta?: { readonly [x: string]: unknown } | null;
},
) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>;
/**
* Sends a generic ACP extension request and records it through the request logger.
Expand Down Expand Up @@ -789,12 +793,13 @@ export const make = (
Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),
Effect.asVoid,
),
setSessionModel: (modelId) =>
setSessionModel: (modelId, options) =>
getStartedState.pipe(
Effect.flatMap((started) => {
const requestPayload = {
sessionId: started.sessionId,
modelId,
...(options?._meta !== undefined ? { _meta: options._meta } : {}),
} satisfies EffectAcpSchema.SetSessionModelRequest;
return runLoggedRequest(
"session/set_model",
Expand Down
Loading
Loading