1import type {
2 ProviderApprovalDecision,
3 ProviderApprovalOption,
4 ProviderUserInputAnswers,
5 UserInputQuestion,
6} from "@t3tools/contracts";
7import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
8import * as Option from "effect/Option";
9import * as Predicate from "effect/Predicate";
10import * as Schema from "effect/Schema";
11import type * as EffectAcpSchema from "effect-acp/compat";
12import * as AcpWireSchema from "effect-acp/schema-v1";
13
14import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
15
16const TOOL_TEXT_LIMIT = 8_000;
17const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n";
18const QUESTION_LABEL_LIMIT = 512;
19
20const NativeToolFields = Schema.Struct({
21 command: Schema.optional(Schema.String),
22 CommandLine: Schema.optional(Schema.String),
23 command_line: Schema.optional(Schema.String),
24 commandLine: Schema.optional(Schema.String),
25 cwd: Schema.optional(Schema.String),
26 Cwd: Schema.optional(Schema.String),
27 WorkingDirectory: Schema.optional(Schema.String),
28 working_dir: Schema.optional(Schema.String),
29 workingDir: Schema.optional(Schema.String),
30 combinedOutput: Schema.optional(Schema.String),
31 combined_output: Schema.optional(Schema.String),
32 exitCode: Schema.optional(Schema.Int),
33 exit_code: Schema.optional(Schema.Int),
34 imagePath: Schema.optional(Schema.String),
35});
36const decodeNativeToolFields = Schema.decodeUnknownOption(NativeToolFields);
37const decodeSingleAnswer = Schema.decodeUnknownOption(
38 Schema.Union([Schema.String, Schema.Tuple([Schema.String])]),
39);
40const decodeToolCallContent = Schema.decodeUnknownOption(AcpWireSchema.ToolCallContent);
41
42/** Native questions share the permission method, but their choices are not approvals. */
43export function isAntigravityUserInputRequest(
44 request: EffectAcpSchema.RequestPermissionRequest,
45): boolean {
46 return request.toolCall.toolCallId.startsWith("interaction_");
47}
48
49export function selectAntigravityPermissionOptionId(
50 request: EffectAcpSchema.RequestPermissionRequest,
51 decision: ProviderApprovalDecision,
52): string | undefined {
53 if (decision === "cancel" || isAntigravityUserInputRequest(request)) {
54 return undefined;
55 }
56 const kind =
57 decision === "accept" ? "allow_once" : decision === "decline" ? "reject_once" : "allow_always";
58 const option = request.options.find((entry) => entry.kind === kind);
59 return option?.optionId.trim() ? option.optionId : undefined;
60}
61
62/** Copy truncated text so V8 cannot retain the original large string. */
63const SECURITY_WARNING_META_KEY = "agy.security.warning";
64const WARNING_TEXT_LIMIT = 512;
65const decodeSecurityWarning = Schema.decodeUnknownOption(
66 Schema.Struct({
67 title: Schema.optional(Schema.String),
68 message: Schema.optional(Schema.String),
69 }),
70);
71
72/**
73 * The agent marks "Allow Always" on shell and web tools with a prompt injection
74 * warning in `_meta`. Surface it as option text so both clients can show it.
75 */
76function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined {
77 const meta = option._meta;
78 if (!Predicate.isObject(meta)) return undefined;
79 const warning = Option.getOrUndefined(decodeSecurityWarning(meta[SECURITY_WARNING_META_KEY]));
80 const text = warning?.message?.trim() || warning?.title?.trim();
81 if (!text) return undefined;
82 return text.length > WARNING_TEXT_LIMIT
83 ? copyBoundedText(`${text.slice(0, WARNING_TEXT_LIMIT - 3)}...`)
84 : text;
85}
86
87/** Only advertise decisions that the native request can honor. */
88export function antigravityApprovalOptions(
89 request: EffectAcpSchema.RequestPermissionRequest,
90): ReadonlyArray<ProviderApprovalOption> {
91 if (isAntigravityUserInputRequest(request)) return [];
92 const options: ProviderApprovalOption[] = [];
93 const optionWithKind = (kind: EffectAcpSchema.PermissionOption["kind"]) =>
94 request.options.find((entry) => entry.kind === kind && entry.optionId.trim());
95 const once = optionWithKind("allow_once");
96 if (once) {
97 options.push({ decision: "accept", label: "Allow once" });
98 }
99 const always = optionWithKind("allow_always");
100 if (always) {
101 const warning = antigravitySecurityWarning(always);
102 options.push({
103 decision: "acceptForSession",
104 label: "Allow for this thread",
105 ...(warning ? { warning } : {}),
106 });
107 }
108 if (optionWithKind("reject_once")) {
109 options.push({ decision: "decline", label: "Deny" });
110 }
111 options.push({ decision: "cancel", label: "Cancel" });
112 return options;
113}
114
115function copyBoundedText(text: string): string {
116 return Buffer.from(text, "utf16le").toString("utf16le");
117}
118
119function questionLabel(option: EffectAcpSchema.PermissionOption): string {
120 const label = option.name.trim() || option.optionId;
121 return label.length > QUESTION_LABEL_LIMIT
122 ? copyBoundedText(`${label.slice(0, QUESTION_LABEL_LIMIT - 3)}...`)
123 : label;
124}
125
126export function extractAntigravityUserInputQuestion(
127 request: EffectAcpSchema.RequestPermissionRequest,
128): UserInputQuestion | undefined {
129 if (!isAntigravityUserInputRequest(request) || request.options.length === 0) {
130 return undefined;
131 }
132 const ids = new Set<string>();
133 for (const option of request.options) {
134 if (!option.optionId.trim() || ids.has(option.optionId)) {
135 return undefined;
136 }
137 ids.add(option.optionId);
138 }
139 const question = request.toolCall.title?.trim() || "Choose an option.";
140 return {
141 id: request.toolCall.toolCallId,
142 header: "Question",
143 question:
144 question.length > TOOL_TEXT_LIMIT
145 ? copyBoundedText(`${question.slice(0, TOOL_TEXT_LIMIT - 3)}...`)
146 : question,
147 multiSelect: false,
148 allowCustomAnswer: false,
149 options: request.options.map((option) => ({
150 value: option.optionId,
151 label: questionLabel(option),
152 description: questionLabel(option),
153 })),
154 };
155}
156
157/** Return undefined for an invalid answer so the adapter keeps the question open. */
158export function makeAntigravityUserInputResponse(
159 request: EffectAcpSchema.RequestPermissionRequest,
160 answers: ProviderUserInputAnswers,
161): EffectAcpSchema.RequestPermissionResponse | undefined {
162 if (extractAntigravityUserInputQuestion(request) === undefined) {
163 return undefined;
164 }
165 const answer = Option.getOrUndefined(decodeSingleAnswer(answers[request.toolCall.toolCallId]));
166 const value = typeof answer === "string" ? answer : answer?.[0];
167 if (value === undefined) {
168 return undefined;
169 }
170 const exact = request.options.find((option) => option.optionId === value);
171 if (exact) {
172 return { outcome: { outcome: "selected", optionId: exact.optionId } };
173 }
174 const matchingLabels = request.options.filter((option) => questionLabel(option) === value);
175 const option = matchingLabels.length === 1 ? matchingLabels[0] : undefined;
176 return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : undefined;
177}
178
179function boundText(text: string, limit = TOOL_TEXT_LIMIT): string {
180 return text.length <= limit
181 ? text
182 : copyBoundedText(`${TOOL_TEXT_TRUNCATED}${text.slice(-limit)}`);
183}
184
185interface ToolPayloadBudget {
186 nodes: number;
187 text: number;
188}
189
190function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: number): unknown {
191 if (depth > 12 || budget.nodes-- <= 0) {
192 return undefined;
193 }
194 if (typeof value === "string") {
195 if (/^data:image\//i.test(value) || budget.text <= 0) {
196 return undefined;
197 }
198 const text = boundText(value, Math.min(TOOL_TEXT_LIMIT, budget.text));
199 budget.text -= text.length;
200 return text;
201 }
202 if (Array.isArray(value)) {
203 const result: unknown[] = [];
204 for (const entry of value) {
205 if (budget.nodes <= 0) break;
206 const sanitized = sanitizeToolValue(entry, budget, depth + 1);
207 if (sanitized !== undefined) result.push(sanitized);
208 }
209 return result;
210 }
211 if (!Predicate.isObject(value)) {
212 return value;
213 }
214 const entries: Array<readonly [string, unknown]> = [];
215 for (const [key, entry] of Object.entries(value)) {
216 if (budget.nodes <= 0) break;
217 if (
218 (value.type === "image" && (key === "data" || key === "blob")) ||
219 (key === "blob" &&
220 typeof value.mimeType === "string" &&
221 value.mimeType.startsWith("image/")) ||
222 ((key === "formatted_output" || key === "formattedOutput") &&
223 (entry === value.combinedOutput || entry === value.combined_output))
224 ) {
225 continue;
226 }
227 const sanitized = sanitizeToolValue(entry, budget, depth + 1);
228 if (sanitized !== undefined) entries.push([key, sanitized]);
229 }
230 return Object.fromEntries(entries);
231}
232
233/** Bound both retained raw events and display data before they enter the event stream. */
234export function sanitizeAntigravityToolPayload(payload: unknown): unknown {
235 return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0);
236}
237
238/** The runtime uses this before it retains tool state or dispatches raw callbacks. */
239export function normalizeAntigravitySessionUpdate(
240 notification: EffectAcpSchema.SessionNotification,
241): EffectAcpSchema.SessionNotification {
242 const update = notification.update;
243 if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
244 return notification;
245 }
246 const contentBudget = { nodes: 512, text: 32_000 };
247 const content = update.content?.flatMap((entry) => {
248 const decoded = Option.getOrUndefined(
249 decodeToolCallContent(sanitizeToolValue(entry, contentBudget, 0)),
250 );
251 return decoded === undefined ? [] : [decoded];
252 });
253 const meta = sanitizeAntigravityToolPayload(update._meta);
254 return {
255 ...notification,
256 update: {
257 ...update,
258 ...(typeof update.title === "string" ? { title: boundText(update.title) } : {}),
259 ...(update.rawInput !== undefined
260 ? { rawInput: sanitizeAntigravityToolPayload(update.rawInput) }
261 : {}),
262 ...(update.rawOutput !== undefined
263 ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) }
264 : {}),
265 ...(update.content !== undefined ? { content: content ?? [] } : {}),
266 ...(update._meta !== undefined
267 ? { _meta: Schema.is(Schema.Record(Schema.String, Schema.Json))(meta) ? meta : null }
268 : {}),
269 },
270 };
271}
272
273function localImagePath(imagePath: string | undefined): string | undefined {
274 if (!imagePath || imagePath.length > TOOL_TEXT_LIMIT) {
275 return undefined;
276 }
277 const path = imagePath.trim();
278 if (!isWorkspaceImagePreviewPath(path)) {
279 return undefined;
280 }
281 if (/^file:\/\//i.test(path)) {
282 try {
283 const url = new URL(path);
284 if (url.hostname && url.hostname !== "localhost") return undefined;
285 const pathname = decodeURIComponent(url.pathname);
286 return /^\/[a-z]:\//i.test(pathname) ? pathname.slice(1) : pathname;
287 } catch {
288 return undefined;
289 }
290 }
291 return /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path) ? undefined : path;
292}
293
294export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToolCallState {
295 const input = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawInput));
296 const output = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawOutput));
297 const nativeCommand =
298 input?.CommandLine ??
299 input?.command_line ??
300 input?.commandLine ??
301 input?.command ??
302 output?.commandLine ??
303 output?.command_line ??
304 toolCall.command;
305 const command = nativeCommand?.trim() ? boundText(nativeCommand.trim()) : undefined;
306 const nativeCwd =
307 input?.Cwd ??
308 input?.WorkingDirectory ??
309 input?.working_dir ??
310 input?.workingDir ??
311 input?.cwd ??
312 output?.workingDir ??
313 output?.working_dir;
314 const cwd = nativeCwd?.trim() ? boundText(nativeCwd.trim()) : undefined;
315 const nativeOutput = output?.combinedOutput ?? output?.combined_output;
316 const aggregatedOutput = nativeOutput === undefined ? undefined : boundText(nativeOutput);
317 const exitCode = output?.exitCode ?? output?.exit_code;
318 const imagePath = localImagePath(output?.imagePath);
319 const sanitizedData = sanitizeAntigravityToolPayload(toolCall.data);
320 const data: Record<string, unknown> = Predicate.isObject(sanitizedData) ? sanitizedData : {};
321 const kind = toolCall.kind ?? (command !== undefined ? "execute" : undefined);
322 if (kind !== undefined) data.kind = kind;
323 if (command !== undefined) data.command = command;
324 if (cwd !== undefined) data.cwd = cwd;
325 if (imagePath !== undefined) data.imagePath = imagePath;
326 if (kind === "execute") {
327 data.item = {
328 ...(Predicate.isObject(data.item) ? data.item : {}),
329 ...(command !== undefined ? { command } : {}),
330 ...(cwd !== undefined ? { cwd } : {}),
331 ...(aggregatedOutput !== undefined ? { aggregatedOutput } : {}),
332 ...(exitCode !== undefined ? { exitCode } : {}),
333 };
334 }
335 return {
336 ...toolCall,
337 ...(kind !== undefined ? { kind } : {}),
338 ...(command !== undefined ? { command } : {}),
339 ...(toolCall.title !== undefined ? { title: boundText(toolCall.title) } : {}),
340 ...(command !== undefined
341 ? { detail: command }
342 : toolCall.detail !== undefined
343 ? { detail: boundText(toolCall.detail) }
344 : {}),
345 data,
346 };
347}
348
349/** Only commands still running after end_turn become background tasks. */
350export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean {
351 return toolCall.kind === "execute" && toolCall.status === "inProgress";
352}
353
354/**
355 * ACP 1.1.1 exposes subagent invocations as ordinary tools, without child IDs
356 * or models. Reads the update's `_meta` from `data.meta`, or from the raw
357 * notification when given.
358 */
359export function classifyAntigravitySubagentToolCall(
360 toolCall: AcpToolCallState,
361 rawPayload?: unknown,
362): "subagent" | "mcp" | undefined {
363 if (
364 (toolCall.kind !== undefined && toolCall.kind !== "other") ||
365 (toolCall.title !== "Running start_subagent" && toolCall.title !== "Run start_subagent?")
366 )
367 return undefined;
368 const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined;
369 const meta = Predicate.isObject(update) ? update._meta : toolCall.data.meta;
370 return Predicate.isObject(meta) && meta.is_mcp_tool_call === true ? "mcp" : "subagent";
371}
372
373/** History sends a completed start before the separate result and its final status. */
374export function isAntigravitySubagentReplayStart(rawPayload: unknown): boolean {
375 const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined;
376 return (
377 Predicate.isObject(update) &&
378 update.sessionUpdate === "tool_call" &&
379 update.status === "completed" &&
380 (update.rawOutput === undefined || update.rawOutput === null)
381 );
382}
383
384export function antigravitySubagentOutput(toolCall: AcpToolCallState): string | undefined {
385 const output = toolCall.data.rawOutput;
386 return typeof output === "string" && output.trim() ? boundText(output.trim()) : undefined;
387}