1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 ProjectId,
5 ProviderInstanceId,
6 ProviderSessionId,
7 type RuntimeMode,
8 ThreadId,
9} from "@t3tools/contracts";
10import { GrokSettings } from "@t3tools/provider-grok/settings";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
13import * as EffectAcpErrors from "effect-acp/errors";
14import { xAiRateLimitedErrorCode } from "@t3tools/provider-grok/testing";
15import { assert, describe, it } from "@effect/vitest";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as PlatformError from "effect/PlatformError";
20import * as Schema from "effect/Schema";
21import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
22import type * as EffectAcpSchema from "effect-acp/compat";
23
24import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
25import * as ProjectStore from "../ProjectStore.ts";
26import { buildInitialGrokProviderSnapshot } from "@t3tools/provider-grok/testing";
27import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
28import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
29import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
30import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
31import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
32import * as RuntimePolicy from "../RuntimePolicy.ts";
33import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy";
34import {
35 AcpProviderCapabilitiesV2,
36 acpCompletedTurnShouldTerminalizeTool,
37 acpSubagentStatusBlocksTurnSettlement,
38 acpSupportsImagePrompts,
39} from "@t3tools/provider-acp/server/adapter";
40import {
41 makeGrokAcpAdapterFlavor,
42 makeGrokAdapterV2,
43 GrokProviderCapabilitiesV2,
44} from "@t3tools/provider-grok/testing";
45
46const LAUNCH_TEST_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({
47 binaryPath: "grok-launch-test",
48});
49
50function permissionRequest(
51 kind: EffectAcpSchema.ToolKind,
52): EffectAcpSchema.RequestPermissionRequest {
53 return {
54 sessionId: "session-1",
55 options: [
56 { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
57 { optionId: "allow-always", name: "Allow always", kind: "allow_always" },
58 { optionId: "reject-once", name: "Reject", kind: "reject_once" },
59 ],
60 toolCall: {
61 toolCallId: "tool-1",
62 title: "Test tool",
63 kind,
64 },
65 };
66}
67
68function runtimePolicy(input: {
69 readonly runtimeMode: RuntimeMode;
70 readonly approvalPolicy?: unknown;
71 readonly sandboxPolicy?: unknown;
72}) {
73 return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
74 runtimeMode: input.runtimeMode,
75 interactionMode: "default",
76 cwd: "/workspace",
77 ...(input.approvalPolicy === undefined ? {} : { approvalPolicy: input.approvalPolicy }),
78 ...(input.sandboxPolicy === undefined ? {} : { sandboxPolicy: input.sandboxPolicy }),
79 });
80}
81
82describe("acpSubagentStatusBlocksTurnSettlement", () => {
83 it("blocks settlement for pending and running subagents", () => {
84 assert.isTrue(acpSubagentStatusBlocksTurnSettlement("pending"));
85 assert.isTrue(acpSubagentStatusBlocksTurnSettlement("running"));
86 });
87
88 it("does not block settlement for terminal subagents", () => {
89 assert.isFalse(acpSubagentStatusBlocksTurnSettlement("cancelled"));
90 assert.isFalse(acpSubagentStatusBlocksTurnSettlement("completed"));
91 assert.isFalse(acpSubagentStatusBlocksTurnSettlement("failed"));
92 assert.isFalse(acpSubagentStatusBlocksTurnSettlement("interrupted"));
93 });
94});
95
96describe("GrokAdapterV2 capabilities", () => {
97 it("preserves Grok's rate-limit stop and distinguishes other prompt failures", () => {
98 const flavor = makeGrokAcpAdapterFlavor(
99 {
100 makeRuntime: () => Effect.never,
101 } as unknown as Parameters<typeof makeGrokAcpAdapterFlavor>[0],
102 "/home/ada",
103 );
104 const limit = flavor.promptFailure?.(
105 new EffectAcpErrors.AcpRequestError({
106 code: xAiRateLimitedErrorCode,
107 errorMessage: "Grok usage limit reached. Try again later.",
108 }),
109 );
110 assert.equal(limit?.class, "usage_limit");
111 assert.equal(limit?.code, String(xAiRateLimitedErrorCode));
112 assert.equal(limit?.message, "Grok usage limit reached. Try again later.");
113 assert.equal(
114 flavor.promptFailure?.(
115 new EffectAcpErrors.AcpRequestError({
116 code: -32603,
117 errorMessage: "Internal error",
118 }),
119 ).class,
120 "provider_error",
121 );
122 assert.equal(
123 flavor.promptFailure?.(new Error("Rate limit mentioned in an ordinary error")).class,
124 "provider_error",
125 );
126 });
127
128 it("wires hard Stop teardown but soft non-Stop interrupts in the constructor flavor", () => {
129 const flavor = makeGrokAcpAdapterFlavor(
130 {
131 makeRuntime: () => Effect.never,
132 } as unknown as Parameters<typeof makeGrokAcpAdapterFlavor>[0],
133 "/home/ada",
134 );
135
136 assert.isFalse(flavor.interruptPromptOnCancel);
137 // User Stop (requestRuntimeRestart) keeps the hard process-group kill and
138 // respawn: Grok cancel is detach-and-continue, so only a process kill
139 // stops the work.
140 assert.isTrue(flavor.restartRuntimeAfterInterrupt);
141 assert.isTrue(flavor.terminateRuntimeProcessGroupOnInterrupt);
142 // Non-Stop interrupts (steering, restart_active) reuse the process and
143 // session; the cancelled work backgrounds and the model decides its fate.
144 assert.isUndefined(flavor.restartRuntimeOnEveryInterrupt);
145 assert.isTrue(flavor.preserveRuntimeOnSettledInterrupt);
146 });
147
148 it("terminalizes only foreground tools under the actual Grok flavor", () => {
149 const flavor = makeGrokAcpAdapterFlavor(
150 {
151 makeRuntime: () => Effect.never,
152 } as unknown as Parameters<typeof makeGrokAcpAdapterFlavor>[0],
153 "/home/ada",
154 );
155 const foreground = {
156 toolCallId: "foreground-1",
157 title: "Terminal",
158 status: "inProgress" as const,
159 data: {
160 rawInput: { command: "true" },
161 rawOutput: { type: "Bash", exit_code: 0 },
162 },
163 };
164 const monitor = {
165 toolCallId: "monitor-1",
166 title: "Monitor",
167 status: "inProgress" as const,
168 data: {
169 rawInput: { variant: "Monitor", command: "sleep 30" },
170 rawOutput: {
171 type: "Monitor",
172 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
173 },
174 },
175 };
176 const subagent = {
177 toolCallId: "subagent-1",
178 title: "Task",
179 status: "inProgress" as const,
180 data: {
181 rawInput: {
182 description: "Inspect interrupt handling",
183 prompt: "Review the adapter.",
184 subagent_type: "generalPurpose",
185 },
186 },
187 };
188
189 assert.isTrue(acpCompletedTurnShouldTerminalizeTool(foreground, flavor));
190 assert.isFalse(acpCompletedTurnShouldTerminalizeTool(monitor, flavor));
191 assert.isFalse(acpCompletedTurnShouldTerminalizeTool(subagent, flavor));
192 });
193
194 it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => {
195 assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession);
196 assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession);
197 assert.isFalse(AcpProviderCapabilitiesV2.threads.canReadThreadSnapshot);
198 assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools);
199 });
200
201 it("overrides ACP image capability false so screenshot attachments can prompt", () => {
202 // Handshake alone would refuse attachments (Grok advertises image:false).
203 assert.isFalse(
204 acpSupportsImagePrompts({
205 negotiatedImage: false,
206 }),
207 );
208 // Flavor override unblocks image content blocks for Grok.
209 assert.isTrue(
210 acpSupportsImagePrompts({
211 flavorSupportsImagePrompts: true,
212 negotiatedImage: false,
213 }),
214 );
215 assert.isTrue(
216 acpSupportsImagePrompts({
217 negotiatedImage: true,
218 }),
219 );
220 });
221
222 it("declares Grok Task envelopes as native subagents", () => {
223 assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread);
224 assert.isTrue(GrokProviderCapabilitiesV2.subagents.supportsSubagents);
225 assert.isTrue(GrokProviderCapabilitiesV2.subagents.exposesSubagentThreadIds);
226 assert.isTrue(GrokProviderCapabilitiesV2.subagents.emitsSubagentLifecycle);
227 assert.isFalse(GrokProviderCapabilitiesV2.turns.supportsActiveSteering);
228 assert.isTrue(GrokProviderCapabilitiesV2.turns.supportsInterrupt);
229 assert.isTrue(GrokProviderCapabilitiesV2.turns.supportsSteeringByInterruptRestart);
230 assert.isTrue(GrokProviderCapabilitiesV2.context.supportsFullThreadHandoff);
231 });
232
233 it("declares the optional ACP features verified by the Grok handshake", () => {
234 assert.isTrue(GrokProviderCapabilitiesV2.sessions.supportsModelSwitchInSession);
235 assert.isTrue(GrokProviderCapabilitiesV2.threads.canReadThreadSnapshot);
236 assert.isTrue(GrokProviderCapabilitiesV2.tools.supportsMcpTools);
237 assert.isTrue(GrokProviderCapabilitiesV2.checkpointing.providerCanReadConversationSnapshot);
238 });
239});
240
241describe("ACP permission policy", () => {
242 it("honors explicit on-request approval over full-access runtime mode", () => {
243 assert.equal(
244 acpPermissionDisposition(
245 runtimePolicy({
246 runtimeMode: "full-access",
247 approvalPolicy: "on-request",
248 sandboxPolicy: { type: "readOnly" },
249 }),
250 permissionRequest("execute"),
251 ),
252 "ask",
253 );
254 });
255
256 it("rejects mutating escalation under a non-interactive read-only policy", () => {
257 const policy = runtimePolicy({
258 runtimeMode: "full-access",
259 approvalPolicy: "never",
260 sandboxPolicy: { type: "readOnly" },
261 });
262 assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny");
263 assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny");
264 assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow");
265 });
266
267 it("auto-approves requests only when the resolved policy permits them", () => {
268 assert.equal(
269 acpPermissionDisposition(
270 runtimePolicy({
271 runtimeMode: "full-access",
272 approvalPolicy: "never",
273 sandboxPolicy: { type: "dangerFullAccess" },
274 }),
275 permissionRequest("execute"),
276 ),
277 "allow",
278 );
279 assert.equal(
280 acpPermissionDisposition(
281 runtimePolicy({ runtimeMode: "approval-required" }),
282 permissionRequest("edit"),
283 ),
284 "ask",
285 );
286 assert.equal(
287 acpPermissionDisposition(
288 runtimePolicy({ runtimeMode: "approval-required" }),
289 permissionRequest("read"),
290 ),
291 "allow",
292 );
293 });
294});
295
296describe("Grok permission prompts", () => {
297 const disposition = makeGrokAcpAdapterFlavor(
298 {
299 makeRuntime: () => Effect.never,
300 } as unknown as Parameters<typeof makeGrokAcpAdapterFlavor>[0],
301 "/home/ada",
302 ).permissionDisposition;
303
304 // grok_auto_blocked_command replays Auto end to end. When an explicit policy
305 // launches Grok asking instead, T3's policy still answers its prompts.
306 it("leaves Auto prompts to the user unless an explicit policy launched Grok asking", () => {
307 assert.equal(
308 disposition?.(runtimePolicy({ runtimeMode: "auto" }), permissionRequest("read")),
309 "ask",
310 );
311 const readOnly = runtimePolicy({
312 runtimeMode: "auto",
313 approvalPolicy: "never",
314 sandboxPolicy: { type: "readOnly" },
315 });
316 assert.equal(disposition?.(readOnly, permissionRequest("execute")), "deny");
317 assert.equal(
318 disposition?.(runtimePolicy({ runtimeMode: "approval-required" }), permissionRequest("edit")),
319 "ask",
320 );
321 });
322});
323
324describe("Grok launch permission mode", () => {
325 const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer));
326 const layerTest = Layer.mergeAll(
327 NodeServices.layer,
328 IdAllocator.layer,
329 McpProviderSessions.layer,
330 ScriptProgress.layerUnavailable,
331 layerHost,
332 );
333
334 // Opens a session through the adapter's own Grok runtime factory and returns
335 // the argv it tried to launch. The spawn fails after recording, so no
336 // process starts.
337 const launchArgs = (runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy) =>
338 Effect.gen(function* () {
339 const launches: Array<ReadonlyArray<string>> = [];
340 const childProcessSpawner = ChildProcessSpawner.make((command) => {
341 if (command._tag === "StandardCommand") launches.push(command.args);
342 return Effect.fail(
343 PlatformError.systemError({
344 _tag: "NotFound",
345 module: "grok-launch-test",
346 method: "spawn",
347 }),
348 );
349 });
350 const instanceId = ProviderInstanceId.make("grok-launch-test");
351 const adapter = yield* makeGrokAdapterV2({
352 instanceId,
353 settings: LAUNCH_TEST_GROK_SETTINGS,
354 environment: {},
355 hostPlatform: "darwin",
356 selfInvocation: yield* resolveSelfInvocation(),
357 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner));
358 yield* adapter
359 .openSession({
360 threadId: ThreadId.make("grok-launch-test"),
361 providerSessionId: ProviderSessionId.make("grok-launch-test"),
362 modelSelection: { instanceId, model: "grok-build" },
363 runtimePolicy,
364 })
365 .pipe(Effect.scoped, Effect.ignore);
366 return launches;
367 }).pipe(
368 // Keep the launch argv unwrapped by the Linux cgroup shim.
369 Effect.provideService(HostProcess.Platform, "darwin"),
370 Effect.provide(layerTest),
371 );
372
373 const policy = (
374 runtimeMode: RuntimeMode,
375 override: Partial<ProviderAdapter.ProviderAdapterV2RuntimePolicy> = {},
376 ) =>
377 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
378 runtimeMode,
379 interactionMode: "default",
380 cwd: process.cwd(),
381 ...override,
382 });
383
384 it.effect.each(
385 (
386 [
387 ["approval-required", ["--permission-mode", "default", "agent", "stdio"]],
388 ["auto", ["--permission-mode", "auto", "agent", "stdio"]],
389 ["full-access", ["agent", "--always-approve", "stdio"]],
390 ] as const
391 ).map(([runtimeMode, args]) => [runtimeMode, args.join(" "), args] as const),
392 )("launches %s threads with %s", ([runtimeMode, , args]) =>
393 Effect.gen(function* () {
394 assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]);
395 }),
396 );
397
398 it.effect("launches a thread stored as Auto-accept edits asking", () =>
399 Effect.gen(function* () {
400 // The policy the orchestrator resolves from Grok's own provider snapshot.
401 const snapshot = yield* buildInitialGrokProviderSnapshot(LAUNCH_TEST_GROK_SETTINGS);
402 const instanceId = ProviderInstanceId.make("grok-launch-test");
403 const now = yield* DateTime.now;
404 const threadId = ThreadId.make("grok-launch-test");
405 const modelSelection = { instanceId, model: "grok-build" } as const;
406 const resolved = yield* Effect.gen(function* () {
407 const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2;
408 return yield* runtimePolicy.resolve({
409 thread: {
410 createdBy: "user",
411 creationSource: "web",
412 id: threadId,
413 projectId: ProjectId.make("grok-launch-test"),
414 title: "Grok launch test",
415 providerInstanceId: instanceId,
416 modelSelection,
417 runtimeMode: "auto-accept-edits",
418 interactionMode: "default",
419 branch: null,
420 worktreePath: process.cwd(),
421 activeProviderThreadId: null,
422 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
423 forkedFrom: null,
424 createdAt: now,
425 updatedAt: now,
426 archivedAt: null,
427 settledOverride: null,
428 settledAt: null,
429 lastVisitedAt: null,
430 deletedAt: null,
431 },
432 modelSelection,
433 });
434 }).pipe(
435 Effect.provide(
436 RuntimePolicy.layerFromProjectStore.pipe(
437 Layer.provide(
438 Layer.mock(ProjectStore.ProjectStoreV2)({
439 get: () => Effect.die("the thread has a worktree"),
440 }),
441 ),
442 Layer.provide(
443 Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
444 getInstance: () =>
445 Effect.succeed({
446 snapshot: { getSnapshot: Effect.succeed(snapshot) },
447 } as ProviderInstance),
448 }),
449 ),
450 ),
451 ),
452 );
453 assert.equal(resolved.runtimeMode, "approval-required");
454 assert.deepEqual(yield* launchArgs(resolved), [
455 ["--permission-mode", "default", "agent", "stdio"],
456 ]);
457 }),
458 );
459
460 it.effect("launches asking when an explicit approval or sandbox policy governs the thread", () =>
461 Effect.gen(function* () {
462 const asking = [["--permission-mode", "default", "agent", "stdio"]];
463 assert.deepEqual(
464 yield* launchArgs(
465 policy("full-access", {
466 approvalPolicy: "never",
467 sandboxPolicy: { type: "workspaceWrite", writableRoots: [], networkAccess: false },
468 }),
469 ),
470 asking,
471 );
472 assert.deepEqual(
473 yield* launchArgs(policy("full-access", { approvalPolicy: "on-request" })),
474 asking,
475 );
476 }),
477 );
478});