diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 4aeca83b7762..3e167bee6ded 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { GrokSettings, + ProjectId, ProviderInstanceId, ProviderSessionId, type RuntimeMode, @@ -12,6 +13,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { xAiRateLimitedErrorCode } from "../../provider/acp/XAiAcpExtension.ts"; import { assert, describe, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -21,8 +23,16 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpSchema from "effect-acp/compat"; import { ServerConfig } from "../../config.ts"; +import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; +import { buildInitialGrokProviderSnapshot } from "../../provider/Layers/GrokProvider.ts"; +import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; +import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import { + layerFromProjectRepository as runtimePolicyLayerFromProjectRepository, + RuntimePolicyV2, +} from "../RuntimePolicy.ts"; import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; import { AcpProviderCapabilitiesV2, @@ -341,7 +351,6 @@ describe("Grok launch permission mode", () => { for (const [runtimeMode, args] of [ ["approval-required", ["--permission-mode", "default", "agent", "stdio"]], - ["auto-accept-edits", ["--permission-mode", "default", "agent", "stdio"]], ["auto", ["--permission-mode", "auto", "agent", "stdio"]], ["full-access", ["agent", "--always-approve", "stdio"]], ] as const) { @@ -352,6 +361,68 @@ describe("Grok launch permission mode", () => { ); } + it.effect("launches a thread stored as Auto-accept edits asking", () => + Effect.gen(function* () { + // The policy the orchestrator resolves from Grok's own provider snapshot. + const snapshot = yield* buildInitialGrokProviderSnapshot(LAUNCH_TEST_GROK_SETTINGS); + const instanceId = ProviderInstanceId.make("grok-launch-test"); + const now = yield* DateTime.now; + const threadId = ThreadId.make("grok-launch-test"); + const modelSelection = { instanceId, model: "grok-build" } as const; + const resolved = yield* Effect.gen(function* () { + const runtimePolicy = yield* RuntimePolicyV2; + return yield* runtimePolicy.resolve({ + thread: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("grok-launch-test"), + title: "Grok launch test", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "auto-accept-edits", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + modelSelection, + }); + }).pipe( + Effect.provide( + runtimePolicyLayerFromProjectRepository.pipe( + Layer.provide( + Layer.mock(ProjectionProjectRepository)({ + getById: () => Effect.die("the thread has a worktree"), + }), + ), + Layer.provide( + Layer.mock(ProviderInstanceRegistry)({ + getInstance: () => + Effect.succeed({ + snapshot: { getSnapshot: Effect.succeed(snapshot) }, + } as ProviderInstance), + }), + ), + ), + ), + ); + assert.equal(resolved.runtimeMode, "approval-required"); + assert.deepEqual(yield* launchArgs(resolved), [ + ["--permission-mode", "default", "agent", "stdio"], + ]); + }), + ); + it.effect("launches asking when an explicit approval or sandbox policy governs the thread", () => Effect.gen(function* () { const asking = [["--permission-mode", "default", "agent", "stdio"]]; diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 567fea60f7c4..8da8378a8b6e 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -79,7 +79,8 @@ const providerInstance = { }, displayName: "Codex test", enabled: true, - snapshot: {} as ProviderInstance["snapshot"], + // No supportedRuntimeModes: every runtime mode runs as stored. + snapshot: { getSnapshot: Effect.succeed({}) } as unknown as ProviderInstance["snapshot"], orchestrationAdapter, textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index f08d5525cb7f..03fd762fd723 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -4,14 +4,19 @@ import { type OrchestrationV2AppThread, ProjectId, ProviderInstanceId, + type RuntimeMode, + type ServerProvider, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; import { layerFromProjectRepository, RuntimePolicyV2 } from "./RuntimePolicy.ts"; const projectId = ProjectId.make("project:runtime-policy"); @@ -24,6 +29,7 @@ const modelSelection = { function makeThread(input: { readonly now: DateTime.Utc; readonly worktreePath: string | null; + readonly runtimeMode?: RuntimeMode; }): OrchestrationV2AppThread { const threadId = ThreadId.make("thread:runtime-policy"); return { @@ -34,7 +40,7 @@ function makeThread(input: { title: "Runtime policy", providerInstanceId, modelSelection, - runtimeMode: "full-access", + runtimeMode: input.runtimeMode ?? "full-access", interactionMode: "default", branch: null, worktreePath: input.worktreePath, @@ -55,7 +61,31 @@ function makeThread(input: { }; } +// Grok's instance offers no Auto-accept edits; the Codex instance advertises no +// restriction. +const grokInstanceId = ProviderInstanceId.make("grok"); +const supportedRuntimeModesByInstance = new Map>([ + [grokInstanceId, ["approval-required", "auto", "full-access"]], +]); +const providerInstanceFor = (instanceId: ProviderInstanceId) => + ({ + snapshot: { + getSnapshot: Effect.succeed({ + supportedRuntimeModes: supportedRuntimeModesByInstance.get(instanceId), + } as ServerProvider), + }, + }) as ProviderInstance; + const TestLayer = layerFromProjectRepository.pipe( + Layer.provide( + Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => Effect.succeed(providerInstanceFor(instanceId)), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }), + ), Layer.provide( Layer.mock(ProjectionProjects.ProjectionProjectRepository)({ getById: () => @@ -101,4 +131,24 @@ it.layer(TestLayer)("RuntimePolicyV2", (it) => { assert.equal(resolved.cwd, "/project-worktree"); }), ); + + it.effect("runs a mode the provider does not offer in Supervised", () => + Effect.gen(function* () { + const policy = yield* RuntimePolicyV2; + const now = yield* DateTime.now; + const modeFor = (instanceId: ProviderInstanceId, runtimeMode: RuntimeMode) => + policy + .resolve({ + thread: makeThread({ now, worktreePath: null, runtimeMode }), + modelSelection: { instanceId, model: "test-model" }, + }) + .pipe(Effect.map((resolved) => resolved.runtimeMode)); + + assert.equal(yield* modeFor(grokInstanceId, "auto-accept-edits"), "approval-required"); + assert.equal(yield* modeFor(grokInstanceId, "auto"), "auto"); + assert.equal(yield* modeFor(grokInstanceId, "full-access"), "full-access"); + // A provider that advertises no restriction runs every mode as stored. + assert.equal(yield* modeFor(providerInstanceId, "auto-accept-edits"), "auto-accept-edits"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index be44653e7af7..4c78c53cfd55 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -3,6 +3,7 @@ import { OrchestrationV2AppThread, ProjectId, ProviderInstanceId, + type RuntimeMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -11,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, @@ -69,16 +71,38 @@ export const layer: Layer.Layer = Layer.succeed(RuntimePolicyV2 }), }); +/** + * The mode a provider runs a thread in. A mode the provider does not offer + * (a thread set before it stopped offering it, or a stale client) runs in + * Supervised rather than having T3 imitate it. + */ +function providerRuntimeMode( + runtimeMode: RuntimeMode, + supportedRuntimeModes: ReadonlyArray | undefined, +): RuntimeMode { + return supportedRuntimeModes === undefined || + supportedRuntimeModes.length === 0 || + supportedRuntimeModes.includes(runtimeMode) + ? runtimeMode + : "approval-required"; +} + export const layerFromProjectRepository: Layer.Layer< RuntimePolicyV2, never, - ProjectionProjects.ProjectionProjectRepository + ProjectionProjects.ProjectionProjectRepository | ProviderInstanceRegistry > = Layer.effect( RuntimePolicyV2, Effect.gen(function* () { const projects = yield* ProjectionProjects.ProjectionProjectRepository; + const providerInstances = yield* ProviderInstanceRegistry; return RuntimePolicyV2.of({ resolve: Effect.fn("RuntimePolicyV2.resolve")(function* (input) { + const instance = yield* providerInstances.getInstance(input.modelSelection.instanceId); + const supportedRuntimeModes = + instance === undefined + ? undefined + : (yield* instance.snapshot.getSnapshot).supportedRuntimeModes; const cwd = input.thread.worktreePath ?? (yield* projects.getById({ projectId: input.thread.projectId }).pipe( @@ -105,7 +129,7 @@ export const layerFromProjectRepository: Layer.Layer< ), )); return ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: input.thread.runtimeMode, + runtimeMode: providerRuntimeMode(input.thread.runtimeMode, supportedRuntimeModes), interactionMode: input.thread.interactionMode, cwd, }); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 19f156d723f3..266bed6d9fdd 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -128,7 +128,8 @@ const providerInstance = { }, displayName: "Codex test", enabled: true, - snapshot: {} as ProviderInstance["snapshot"], + // No supportedRuntimeModes: every runtime mode runs as stored. + snapshot: { getSnapshot: Effect.succeed({}) } as unknown as ProviderInstance["snapshot"], orchestrationAdapter, textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 7096d7d48f5f..9da0c08cff4a 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -37,6 +37,7 @@ import { } from "../providerMaintenance.ts"; import { GROK_DEFAULT_MODEL_SLUG, + GROK_SUPPORTED_RUNTIME_MODES, isValidGrokReasoningEffortToken, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, @@ -48,6 +49,7 @@ const GROK_PRESENTATION = { displayName: "Grok", supportsConversationRollback: false, showInteractionModeToggle: false, + supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES, } as const; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], diff --git a/apps/server/src/provider/acp/AcpClientPolicy.test.ts b/apps/server/src/provider/acp/AcpClientPolicy.test.ts index 463b8fcf1edc..9dabeda83ebf 100644 --- a/apps/server/src/provider/acp/AcpClientPolicy.test.ts +++ b/apps/server/src/provider/acp/AcpClientPolicy.test.ts @@ -92,7 +92,7 @@ describe("acpPermissionDisposition", () => { }); it("auto-accept-edits approves file changes without locations and asks for the rest", () => { - // Grok's session/request_permission carries no locations. + // ACP permission requests need not carry locations. const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd }; for (const kind of ["edit", "delete", "move"] as const) { assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow"); diff --git a/apps/server/src/provider/acp/AcpClientPolicy.ts b/apps/server/src/provider/acp/AcpClientPolicy.ts index ce3e18c07443..1a7c3f5bb231 100644 --- a/apps/server/src/provider/acp/AcpClientPolicy.ts +++ b/apps/server/src/provider/acp/AcpClientPolicy.ts @@ -209,7 +209,7 @@ function acpOperationDisposition( case undefined: if (runtimePolicy.runtimeMode === "approval-required") return "deny"; // Auto-accept edits approves file changes wherever the agent makes them - // (Grok's prompts carry no locations to confine); other actions still ask. + // (ACP prompts need not carry locations to confine); other actions still ask. if ( runtimePolicy.runtimeMode === "auto-accept-edits" && runtimePolicy.approvalPolicy === undefined && diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index d766abad06d1..d15b12fbc2da 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -61,14 +61,14 @@ describe("grokAcpSpawnArgs", () => { expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]); }); - it("launches Auto-accept edits asking and Auto on Grok's classifier", () => { + it("launches Auto on Grok's classifier and a mode Grok does not offer asking", () => { + expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]); expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([ "--permission-mode", "default", "agent", "stdio", ]); - expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]); }); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index ce3611a9097f..7b50e566599b 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -32,24 +32,32 @@ interface GrokAcpRuntimeInput extends Omit< readonly runtimeMode?: RuntimeMode; } +/** + * The runtime modes `grok agent` can launch in: ask, its auto classifier, and + * always-approve. It has no Auto-accept edits: `acceptEdits` only exists as a + * settings-file `permissions.defaultMode`, and `grok agent` treats it as ask. + */ +export const GROK_SUPPORTED_RUNTIME_MODES = [ + "approval-required", + "auto", + "full-access", +] as const satisfies ReadonlyArray; + /** * Launch argv for a runtime mode. `--permission-mode` on the argv beats the * user's Grok config, so Supervised cannot inherit a configured always-approve. - * `grok agent` only wires always-approve and auto at launch; `acceptEdits` - * would behave exactly like `default`, so Auto-accept edits launches asking - * and T3's ACP client policy approves the edit prompts. + * A mode Grok does not offer launches asking. */ export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray { switch (runtimeMode) { - case "approval-required": - case "auto-accept-edits": - return ["--permission-mode", "default", "agent", "stdio"]; + case undefined: + return ["agent", "stdio"]; case "auto": return ["--permission-mode", "auto", "agent", "stdio"]; case "full-access": return ["agent", "--always-approve", "stdio"]; default: - return ["agent", "stdio"]; + return ["--permission-mode", "default", "agent", "stdio"]; } }