1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 ProjectId,
5 MessageId,
6 NodeId,
7 RunAttemptId,
8 RunId,
9 type OrchestrationV2UserInputQuestion,
10 type OrchestrationV2RuntimeRequest,
11 ProviderInstanceId,
12 ProviderSessionId,
13 type RuntimeMode,
14 ThreadId,
15} from "@t3tools/contracts";
16import { GrokSettings } from "@t3tools/provider-grok/settings";
17import * as HostProcess from "@t3tools/shared/HostProcess";
18import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
19import * as EffectAcpErrors from "effect-acp/errors";
20import { xAiRateLimitedErrorCode } from "@t3tools/provider-grok/testing";
21import { assert, describe, it } from "@effect/vitest";
22import * as DateTime from "effect/DateTime";
23import * as Effect from "effect/Effect";
24import * as Deferred from "effect/Deferred";
25import * as FileSystem from "effect/FileSystem";
26import * as Path from "effect/Path";
27import * as Stream from "effect/Stream";
28import * as Layer from "effect/Layer";
29import * as PlatformError from "effect/PlatformError";
30import * as Schema from "effect/Schema";
31import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
32import type * as EffectAcpSchema from "effect-acp/compat";
33
34import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
35import * as ProjectStore from "../ProjectStore.ts";
36import { buildInitialGrokProviderSnapshot } from "@t3tools/provider-grok/testing";
37import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
38import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
39import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
40import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
41import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
42import * as RuntimePolicy from "../RuntimePolicy.ts";
43import * as ServerSettings from "../../serverSettings.ts";
44import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy";
45import { acpCompletedTurnShouldTerminalizeTool } from "@t3tools/provider-acp/server/adapter";
46import {
47 makeGrokAcpAdapterFlavor,
48 makeGrokAdapterV2,
49 GROK_PROVIDER,
50 GROK_ACP_INITIALIZE_META,
51} from "@t3tools/provider-grok/testing";
52
53import { makeAcpReplayRuntime, type AcpReplayTranscript } from "./AcpAdapterV2.testkit.ts";
54
55const LAUNCH_TEST_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({
56 binaryPath: "grok-launch-test",
57});
58
59function permissionRequest(
60 kind: EffectAcpSchema.ToolKind,
61): EffectAcpSchema.RequestPermissionRequest {
62 return {
63 sessionId: "session-1",
64 options: [
65 { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
66 { optionId: "allow-always", name: "Allow always", kind: "allow_always" },
67 { optionId: "reject-once", name: "Reject", kind: "reject_once" },
68 ],
69 toolCall: {
70 toolCallId: "tool-1",
71 title: "Test tool",
72 kind,
73 },
74 };
75}
76
77function runtimePolicy(input: {
78 readonly runtimeMode: RuntimeMode;
79 readonly approvalPolicy?: unknown;
80 readonly sandboxPolicy?: unknown;
81}) {
82 return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
83 runtimeMode: input.runtimeMode,
84 interactionMode: "default",
85 cwd: "/workspace",
86 ...(input.approvalPolicy === undefined ? {} : { approvalPolicy: input.approvalPolicy }),
87 ...(input.sandboxPolicy === undefined ? {} : { sandboxPolicy: input.sandboxPolicy }),
88 });
89}
90
91describe("GrokAdapterV2 capabilities", () => {
92 it("preserves Grok's rate-limit stop and distinguishes other prompt failures", () => {
93 const flavor = makeGrokAcpAdapterFlavor(
94 {
95 makeRuntime: () => Effect.never,
96 } as unknown as Parameters<typeof makeGrokAcpAdapterFlavor>[0],
97 "/home/ada",
98 );
99 const limit = flavor.promptFailure?.(
100 new EffectAcpErrors.AcpRequestError({
101 code: xAiRateLimitedErrorCode,
102 errorMessage: "Grok usage limit reached. Try again later.",
103 }),
104 );
105 assert.equal(limit?.class, "usage_limit");
106 assert.equal(limit?.code, String(xAiRateLimitedErrorCode));
107 assert.equal(limit?.message, "Grok usage limit reached. Try again later.");
108 assert.equal(
109 flavor.promptFailure?.(
110 new EffectAcpErrors.AcpRequestError({
111 code: -32603,
112 errorMessage: "Internal error",
113 }),
114 ).class,
115 "provider_error",
116 );
117 assert.equal(
118 flavor.promptFailure?.(new Error("Rate limit mentioned in an ordinary error")).class,
119 "provider_error",
120 );
121 });
122
123 it("terminalizes only foreground tools under the actual Grok flavor", () => {
124 const flavor = makeGrokAcpAdapterFlavor(
125 {
126 makeRuntime: () => Effect.never,
127 } as unknown as Parameters<typeof makeGrokAcpAdapterFlavor>[0],
128 "/home/ada",
129 );
130 const foreground = {
131 toolCallId: "foreground-1",
132 title: "Terminal",
133 status: "inProgress" as const,
134 data: {
135 rawInput: { command: "true" },
136 rawOutput: { type: "Bash", exit_code: 0 },
137 },
138 };
139 const monitor = {
140 toolCallId: "monitor-1",
141 title: "Monitor",
142 status: "inProgress" as const,
143 data: {
144 rawInput: { variant: "Monitor", command: "sleep 30" },
145 rawOutput: {
146 type: "Monitor",
147 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
148 },
149 },
150 };
151 const subagent = {
152 toolCallId: "subagent-1",
153 title: "Task",
154 status: "inProgress" as const,
155 data: {
156 rawInput: {
157 description: "Inspect interrupt handling",
158 prompt: "Review the adapter.",
159 subagent_type: "generalPurpose",
160 },
161 },
162 };
163
164 assert.isTrue(acpCompletedTurnShouldTerminalizeTool(foreground, flavor));
165 assert.isFalse(acpCompletedTurnShouldTerminalizeTool(monitor, flavor));
166 assert.isFalse(acpCompletedTurnShouldTerminalizeTool(subagent, flavor));
167 });
168});
169
170describe("ACP permission policy", () => {
171 it("rejects mutating escalation under a non-interactive read-only policy", () => {
172 const policy = runtimePolicy({
173 runtimeMode: "full-access",
174 approvalPolicy: "never",
175 sandboxPolicy: { type: "readOnly" },
176 });
177 assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny");
178 assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny");
179 assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow");
180 });
181
182 it("auto-approves requests only when the resolved policy permits them", () => {
183 assert.equal(
184 acpPermissionDisposition(
185 runtimePolicy({
186 runtimeMode: "full-access",
187 approvalPolicy: "never",
188 sandboxPolicy: { type: "dangerFullAccess" },
189 }),
190 permissionRequest("execute"),
191 ),
192 "allow",
193 );
194 assert.equal(
195 acpPermissionDisposition(
196 runtimePolicy({ runtimeMode: "approval-required" }),
197 permissionRequest("edit"),
198 ),
199 "ask",
200 );
201 assert.equal(
202 acpPermissionDisposition(
203 runtimePolicy({ runtimeMode: "approval-required" }),
204 permissionRequest("read"),
205 ),
206 "allow",
207 );
208 });
209});
210
211describe("Grok permission prompts", () => {
212 const disposition = makeGrokAcpAdapterFlavor(
213 {
214 makeRuntime: () => Effect.never,
215 } as unknown as Parameters<typeof makeGrokAcpAdapterFlavor>[0],
216 "/home/ada",
217 ).permissionDisposition;
218
219 it("leaves Auto prompts to the user unless an explicit policy launched Grok asking", () => {
220 assert.equal(
221 disposition?.(runtimePolicy({ runtimeMode: "auto" }), permissionRequest("read")),
222 "ask",
223 );
224 const readOnly = runtimePolicy({
225 runtimeMode: "auto",
226 approvalPolicy: "never",
227 sandboxPolicy: { type: "readOnly" },
228 });
229 assert.equal(disposition?.(readOnly, permissionRequest("execute")), "deny");
230 assert.equal(
231 disposition?.(runtimePolicy({ runtimeMode: "approval-required" }), permissionRequest("edit")),
232 "ask",
233 );
234 });
235});
236
237describe("Grok launch permission mode", () => {
238 const layerHost = TestProviderHost.layer().pipe(
239 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
240 );
241 const layerTest = Layer.mergeAll(
242 NodeServices.layer,
243 NodeCrypto.layer,
244 IdAllocator.layer,
245 McpProviderSessions.layer,
246 layerHost,
247 );
248
249 const launchArgs = (runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy) =>
250 Effect.gen(function* () {
251 const launches: Array<ReadonlyArray<string>> = [];
252 const childProcessSpawner = ChildProcessSpawner.make((command) => {
253 if (command._tag === "StandardCommand") launches.push(command.args.map(String));
254 return Effect.fail(
255 PlatformError.systemError({
256 _tag: "NotFound",
257 module: "grok-launch-test",
258 method: "spawn",
259 }),
260 );
261 });
262 const instanceId = ProviderInstanceId.make("grok-launch-test");
263 const adapter = yield* makeGrokAdapterV2({
264 instanceId,
265 settings: LAUNCH_TEST_GROK_SETTINGS,
266 environment: {},
267 hostPlatform: "darwin",
268 selfInvocation: yield* resolveSelfInvocation(),
269 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner));
270 yield* adapter
271 .openSession({
272 threadId: ThreadId.make("grok-launch-test"),
273 providerSessionId: ProviderSessionId.make("grok-launch-test"),
274 modelSelection: { instanceId, model: "grok-build" },
275 runtimePolicy,
276 })
277 .pipe(Effect.scoped, Effect.ignore);
278 return launches;
279 }).pipe(Effect.provideService(HostProcess.Platform, "darwin"), Effect.provide(layerTest));
280
281 const policy = (
282 runtimeMode: RuntimeMode,
283 override: Partial<ProviderAdapter.ProviderAdapterV2RuntimePolicy> = {},
284 ) =>
285 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
286 runtimeMode,
287 interactionMode: "default",
288 cwd: process.cwd(),
289 ...override,
290 });
291
292 it.effect.each(
293 (
294 [
295 ["approval-required", ["--permission-mode", "default", "agent", "stdio"]],
296 ["auto", ["--permission-mode", "auto", "agent", "stdio"]],
297 ["full-access", ["agent", "--always-approve", "stdio"]],
298 ] as const
299 ).map(([runtimeMode, args]) => [runtimeMode, args.join(" "), args] as const),
300 )("launches %s threads with %s", ([runtimeMode, , args]) =>
301 Effect.gen(function* () {
302 assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]);
303 }),
304 );
305
306 it.effect("launches a thread stored as Auto-accept edits asking", () =>
307 Effect.gen(function* () {
308 const snapshot = yield* buildInitialGrokProviderSnapshot(LAUNCH_TEST_GROK_SETTINGS);
309 const instanceId = ProviderInstanceId.make("grok-launch-test");
310 const now = yield* DateTime.now;
311 const threadId = ThreadId.make("grok-launch-test");
312 const modelSelection = { instanceId, model: "grok-build" } as const;
313 const resolved = yield* Effect.gen(function* () {
314 const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2;
315 return yield* runtimePolicy.resolve({
316 thread: {
317 createdBy: "user",
318 creationSource: "web",
319 id: threadId,
320 projectId: ProjectId.make("grok-launch-test"),
321 title: "Grok launch test",
322 providerInstanceId: instanceId,
323 modelSelection,
324 runtimeMode: "auto-accept-edits",
325 interactionMode: "default",
326 branch: null,
327 worktreePath: process.cwd(),
328 activeProviderThreadId: null,
329 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
330 forkedFrom: null,
331 createdAt: now,
332 updatedAt: now,
333 archivedAt: null,
334 settledOverride: null,
335 settledAt: null,
336 lastVisitedAt: null,
337 deletedAt: null,
338 },
339 modelSelection,
340 });
341 }).pipe(
342 Effect.provide(
343 RuntimePolicy.layerFromProjectStore.pipe(
344 Layer.provide(ServerSettings.layerTest()),
345 Layer.provide(
346 Layer.mock(ProjectStore.ProjectStoreV2)({
347 get: () => Effect.die("the thread has a worktree"),
348 }),
349 ),
350 Layer.provide(
351 Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
352 getInstance: () =>
353 Effect.succeed({
354 snapshot: { getSnapshot: Effect.succeed(snapshot) },
355 } as ProviderInstance),
356 }),
357 ),
358 ),
359 ),
360 );
361 assert.equal(resolved.runtimeMode, "approval-required");
362 assert.deepEqual(yield* launchArgs(resolved), [
363 ["--permission-mode", "default", "agent", "stdio"],
364 ]);
365 }),
366 );
367});
368
369describe("Grok native questions", () => {
370 const layerHost = TestProviderHost.layer().pipe(
371 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
372 );
373 const layerTest = Layer.mergeAll(
374 NodeServices.layer,
375 NodeCrypto.layer,
376 IdAllocator.layer,
377 McpProviderSessions.layer,
378 layerHost,
379 );
380
381 it.live("keeps x.ai multi-select questions live and returns all selected answers", () =>
382 Effect.gen(function* () {
383 const fs = yield* FileSystem.FileSystem;
384 const path = yield* Path.Path;
385 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
386 const workspace = yield* fs.makeTempDirectoryScoped({ prefix: "grok-multi-select-" });
387 const questionText = "Which changes should be included?";
388 const transcript: AcpReplayTranscript = {
389 provider: GROK_PROVIDER,
390 protocol: "acp.ndjson-jsonrpc",
391 version: "1",
392 scenario: "grok-multi-select",
393 entries: [
394 {
395 type: "expect_outbound",
396 label: "initialize",
397 frame: { kind: "request", method: "initialize", params: "<any>" },
398 },
399 {
400 type: "emit_inbound",
401 label: "initialized",
402 frame: {
403 kind: "response",
404 method: "initialize",
405 result: {
406 protocolVersion: 1,
407 agentCapabilities: { loadSession: true, promptCapabilities: { image: false } },
408 authMethods: [{ id: "replay", name: "Replay" }],
409 },
410 },
411 },
412 {
413 type: "expect_outbound",
414 label: "session.new",
415 frame: { kind: "request", method: "session/new", params: "<any>" },
416 },
417 {
418 type: "emit_inbound",
419 label: "session.created",
420 frame: {
421 kind: "response",
422 method: "session/new",
423 result: {
424 sessionId: "grok-multi-select-session",
425 models: {
426 currentModelId: "grok-build",
427 availableModels: [{ modelId: "grok-build", name: "Grok Build" }],
428 },
429 },
430 },
431 },
432 {
433 type: "expect_outbound",
434 label: "prompt",
435 frame: { kind: "request", method: "session/prompt", params: "<any>" },
436 },
437 {
438 type: "emit_inbound",
439 label: "question",
440 frame: {
441 kind: "request",
442 method: "_x.ai/ask_user_question",
443 params: {
444 method: "x.ai/ask_user_question",
445 params: {
446 sessionId: "grok-multi-select-session",
447 toolCallId: "grok-question",
448 mode: "default",
449 questions: [
450 {
451 id: "changes",
452 question: questionText,
453 multiSelect: true,
454 options: [
455 { label: "Tests", description: "Cover behavior" },
456 { label: "Docs", description: "Explain usage" },
457 ],
458 },
459 ],
460 },
461 },
462 },
463 },
464 {
465 type: "expect_outbound",
466 label: "selected answers",
467 frame: {
468 kind: "response",
469 method: "_x.ai/ask_user_question",
470 result: { outcome: "accepted", answers: { [questionText]: ["Tests", "Docs"] } },
471 },
472 },
473 {
474 type: "emit_inbound",
475 label: "complete",
476 frame: {
477 kind: "response",
478 method: "session/prompt",
479 result: { stopReason: "end_turn" },
480 },
481 },
482 ],
483 };
484 const statusPath = path.join(workspace, "status.json");
485 const scriptPath = yield* path.fromFileUrl(
486 new URL("../../../scripts/acp-replay-agent.ts", import.meta.url),
487 );
488 const instanceId = ProviderInstanceId.make("grok-multi-select");
489 const threadId = ThreadId.make("grok-multi-select-thread");
490 const adapter = yield* makeGrokAdapterV2({
491 instanceId,
492 settings: LAUNCH_TEST_GROK_SETTINGS,
493 environment: {},
494 hostPlatform: "darwin",
495 selfInvocation: yield* resolveSelfInvocation(),
496 makeRuntime: makeAcpReplayRuntime({
497 transcript,
498 statusPath,
499 scriptPath,
500 childProcessSpawner,
501 fileSystem: fs,
502 initializeMeta: GROK_ACP_INITIALIZE_META,
503 }),
504 });
505 const policy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
506 runtimeMode: "approval-required",
507 interactionMode: "default",
508 cwd: workspace,
509 });
510 const modelSelection = { instanceId, model: "grok-build" };
511 const runtime = yield* adapter.openSession({
512 threadId,
513 providerSessionId: ProviderSessionId.make("grok-multi-select-session"),
514 modelSelection,
515 runtimePolicy: policy,
516 });
517 const providerThread = yield* runtime.ensureThread({
518 threadId,
519 modelSelection,
520 runtimePolicy: policy,
521 });
522 const question = yield* Deferred.make<OrchestrationV2UserInputQuestion>();
523 const request = yield* Deferred.make<OrchestrationV2RuntimeRequest>();
524 const terminal = yield* Deferred.make<void>();
525 yield* runtime.events.pipe(
526 Stream.runForEach((event) => {
527 if (
528 event.type === "turn_item.updated" &&
529 event.turnItem.type === "user_input_request" &&
530 event.turnItem.questions[0]
531 )
532 return Deferred.succeed(question, event.turnItem.questions[0]);
533 if (event.type === "runtime_request.updated")
534 return Deferred.succeed(request, event.runtimeRequest);
535 if (event.type === "turn.terminal") return Deferred.succeed(terminal, undefined);
536 return Effect.void;
537 }),
538 Effect.forkScoped,
539 );
540 const now = yield* DateTime.now;
541 yield* runtime.startTurn({
542 appThread: {
543 createdBy: "user",
544 creationSource: "web",
545 id: threadId,
546 projectId: ProjectId.make("grok-multi-select-project"),
547 title: "Grok multi-select",
548 providerInstanceId: instanceId,
549 modelSelection,
550 runtimeMode: "approval-required",
551 interactionMode: "default",
552 branch: null,
553 worktreePath: workspace,
554 activeProviderThreadId: providerThread.id,
555 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
556 forkedFrom: null,
557 createdAt: now,
558 updatedAt: now,
559 archivedAt: null,
560 settledOverride: null,
561 settledAt: null,
562 lastVisitedAt: null,
563 deletedAt: null,
564 },
565 threadId,
566 runId: RunId.make("grok-multi-select-run"),
567 runOrdinal: 1,
568 providerTurnOrdinal: 1,
569 attemptId: RunAttemptId.make("grok-multi-select-attempt"),
570 rootNodeId: NodeId.make("grok-multi-select-root"),
571 providerThread,
572 message: {
573 createdBy: "user",
574 creationSource: "web",
575 messageId: MessageId.make("grok-multi-select-message"),
576 text: "Ask which changes to include.",
577 attachments: [],
578 },
579 modelSelection,
580 runtimePolicy: policy,
581 });
582 assert.deepEqual(yield* Deferred.await(question), {
583 id: "changes",
584 header: "Question",
585 question: questionText,
586 multiSelect: true,
587 options: [
588 { label: "Tests", description: "Cover behavior" },
589 { label: "Docs", description: "Explain usage" },
590 ],
591 });
592 const pending = yield* Deferred.await(request);
593 assert.equal(pending.kind, "user_input");
594 assert.equal(pending.responseCapability.type, "live");
595 yield* runtime.respondToRuntimeRequest({
596 requestId: pending.id,
597 answers: { changes: ["Tests", "Docs"] },
598 });
599 yield* Deferred.await(terminal);
600 }).pipe(Effect.scoped, Effect.provide(layerTest)),
601 );
602});