import type { ProviderApprovalDecision, ProviderApprovalOption, ProviderUserInputAnswers, UserInputQuestion, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as EffectAcpSchema from "effect-acp/schema"; import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; const TOOL_TEXT_LIMIT = 8_000; const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; const QUESTION_LABEL_LIMIT = 512; const NativeToolFields = Schema.Struct({ command: Schema.optional(Schema.String), CommandLine: Schema.optional(Schema.String), command_line: Schema.optional(Schema.String), commandLine: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), Cwd: Schema.optional(Schema.String), WorkingDirectory: Schema.optional(Schema.String), working_dir: Schema.optional(Schema.String), workingDir: Schema.optional(Schema.String), combinedOutput: Schema.optional(Schema.String), combined_output: Schema.optional(Schema.String), exitCode: Schema.optional(Schema.Int), exit_code: Schema.optional(Schema.Int), imagePath: Schema.optional(Schema.String), }); const decodeNativeToolFields = Schema.decodeUnknownOption(NativeToolFields); const decodeSingleAnswer = Schema.decodeUnknownOption( Schema.Union([Schema.String, Schema.Tuple([Schema.String])]), ); const decodeToolCallContent = Schema.decodeUnknownOption(EffectAcpSchema.ToolCallContent); /** Native questions share the permission method, but their choices are not approvals. */ export function isAntigravityUserInputRequest( request: EffectAcpSchema.RequestPermissionRequest, ): boolean { return request.toolCall.toolCallId.startsWith("interaction_"); } export function selectAntigravityPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: ProviderApprovalDecision, ): string | undefined { if (decision === "cancel" || isAntigravityUserInputRequest(request)) { return undefined; } const kind = decision === "accept" ? "allow_once" : decision === "decline" ? "reject_once" : "allow_always"; const option = request.options.find((entry) => entry.kind === kind); return option?.optionId.trim() ? option.optionId : undefined; } /** Copy truncated text so V8 cannot retain the original large string. */ const SECURITY_WARNING_META_KEY = "agy.security.warning"; const WARNING_TEXT_LIMIT = 512; const decodeSecurityWarning = Schema.decodeUnknownOption( Schema.Struct({ title: Schema.optional(Schema.String), message: Schema.optional(Schema.String), }), ); /** * The agent marks "Allow Always" on shell and web tools with a prompt injection * warning in `_meta`. Surface it as option text so both clients can show it. */ function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined { const meta = option._meta; if (!Predicate.isObject(meta)) return undefined; const warning = Option.getOrUndefined(decodeSecurityWarning(meta[SECURITY_WARNING_META_KEY])); const text = warning?.message?.trim() || warning?.title?.trim(); if (!text) return undefined; return text.length > WARNING_TEXT_LIMIT ? copyBoundedText(`${text.slice(0, WARNING_TEXT_LIMIT - 3)}...`) : text; } /** Only advertise decisions that the native request can honor. */ export function antigravityApprovalOptions( request: EffectAcpSchema.RequestPermissionRequest, ): ReadonlyArray { if (isAntigravityUserInputRequest(request)) return []; const options: ProviderApprovalOption[] = []; const optionWithKind = (kind: EffectAcpSchema.PermissionOption["kind"]) => request.options.find((entry) => entry.kind === kind && entry.optionId.trim()); const once = optionWithKind("allow_once"); if (once) { options.push({ decision: "accept", label: "Allow once" }); } const always = optionWithKind("allow_always"); if (always) { const warning = antigravitySecurityWarning(always); options.push({ decision: "acceptForSession", label: "Allow for this thread", ...(warning ? { warning } : {}), }); } if (optionWithKind("reject_once")) { options.push({ decision: "decline", label: "Deny" }); } options.push({ decision: "cancel", label: "Cancel" }); return options; } function copyBoundedText(text: string): string { return Buffer.from(text, "utf16le").toString("utf16le"); } function questionLabel(option: EffectAcpSchema.PermissionOption): string { const label = option.name.trim() || option.optionId; return label.length > QUESTION_LABEL_LIMIT ? copyBoundedText(`${label.slice(0, QUESTION_LABEL_LIMIT - 3)}...`) : label; } export function extractAntigravityUserInputQuestion( request: EffectAcpSchema.RequestPermissionRequest, ): UserInputQuestion | undefined { if (!isAntigravityUserInputRequest(request) || request.options.length === 0) { return undefined; } const ids = new Set(); for (const option of request.options) { if (!option.optionId.trim() || ids.has(option.optionId)) { return undefined; } ids.add(option.optionId); } const question = request.toolCall.title?.trim() || "Choose an option."; return { id: request.toolCall.toolCallId, header: "Question", question: question.length > TOOL_TEXT_LIMIT ? copyBoundedText(`${question.slice(0, TOOL_TEXT_LIMIT - 3)}...`) : question, multiSelect: false, allowCustomAnswer: false, options: request.options.map((option) => ({ value: option.optionId, label: questionLabel(option), description: questionLabel(option), })), }; } /** Return undefined for an invalid answer so the adapter keeps the question open. */ export function makeAntigravityUserInputResponse( request: EffectAcpSchema.RequestPermissionRequest, answers: ProviderUserInputAnswers, ): EffectAcpSchema.RequestPermissionResponse | undefined { if (extractAntigravityUserInputQuestion(request) === undefined) { return undefined; } const answer = Option.getOrUndefined(decodeSingleAnswer(answers[request.toolCall.toolCallId])); const value = typeof answer === "string" ? answer : answer?.[0]; if (value === undefined) { return undefined; } const exact = request.options.find((option) => option.optionId === value); if (exact) { return { outcome: { outcome: "selected", optionId: exact.optionId } }; } const matchingLabels = request.options.filter((option) => questionLabel(option) === value); const option = matchingLabels.length === 1 ? matchingLabels[0] : undefined; return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : undefined; } function boundText(text: string, limit = TOOL_TEXT_LIMIT): string { return text.length <= limit ? text : copyBoundedText(`${TOOL_TEXT_TRUNCATED}${text.slice(-limit)}`); } interface ToolPayloadBudget { nodes: number; text: number; } function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: number): unknown { if (depth > 12 || budget.nodes-- <= 0) { return undefined; } if (typeof value === "string") { if (/^data:image\//i.test(value) || budget.text <= 0) { return undefined; } const text = boundText(value, Math.min(TOOL_TEXT_LIMIT, budget.text)); budget.text -= text.length; return text; } if (Array.isArray(value)) { const result: unknown[] = []; for (const entry of value) { if (budget.nodes <= 0) break; const sanitized = sanitizeToolValue(entry, budget, depth + 1); if (sanitized !== undefined) result.push(sanitized); } return result; } if (!Predicate.isObject(value)) { return value; } const entries: Array = []; for (const [key, entry] of Object.entries(value)) { if (budget.nodes <= 0) break; if ( (value.type === "image" && (key === "data" || key === "blob")) || (key === "blob" && typeof value.mimeType === "string" && value.mimeType.startsWith("image/")) || ((key === "formatted_output" || key === "formattedOutput") && (entry === value.combinedOutput || entry === value.combined_output)) ) { continue; } const sanitized = sanitizeToolValue(entry, budget, depth + 1); if (sanitized !== undefined) entries.push([key, sanitized]); } return Object.fromEntries(entries); } /** Bound both retained raw events and display data before they enter the event stream. */ export function sanitizeAntigravityToolPayload(payload: unknown): unknown { return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0); } /** The runtime uses this before it retains tool state or dispatches raw callbacks. */ export function normalizeAntigravitySessionUpdate( notification: EffectAcpSchema.SessionNotification, ): EffectAcpSchema.SessionNotification { const update = notification.update; if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { return notification; } const contentBudget = { nodes: 512, text: 32_000 }; const content = update.content?.flatMap((entry) => { const decoded = Option.getOrUndefined( decodeToolCallContent(sanitizeToolValue(entry, contentBudget, 0)), ); return decoded === undefined ? [] : [decoded]; }); const meta = sanitizeAntigravityToolPayload(update._meta); return { ...notification, update: { ...update, ...(typeof update.title === "string" ? { title: boundText(update.title) } : {}), ...(update.rawInput !== undefined ? { rawInput: sanitizeAntigravityToolPayload(update.rawInput) } : {}), ...(update.rawOutput !== undefined ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) } : {}), ...(update.content !== undefined ? { content: content ?? [] } : {}), ...(update._meta !== undefined ? { _meta: Predicate.isObject(meta) ? meta : null } : {}), }, }; } function localImagePath(imagePath: string | undefined): string | undefined { if (!imagePath || imagePath.length > TOOL_TEXT_LIMIT) { return undefined; } const path = imagePath.trim(); if (!isWorkspaceImagePreviewPath(path)) { return undefined; } if (/^file:\/\//i.test(path)) { try { const url = new URL(path); if (url.hostname && url.hostname !== "localhost") return undefined; const pathname = decodeURIComponent(url.pathname); return /^\/[a-z]:\//i.test(pathname) ? pathname.slice(1) : pathname; } catch { return undefined; } } return /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path) ? undefined : path; } export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToolCallState { const input = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawInput)); const output = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawOutput)); const nativeCommand = input?.CommandLine ?? input?.command_line ?? input?.commandLine ?? input?.command ?? output?.commandLine ?? output?.command_line ?? toolCall.command; const command = nativeCommand?.trim() ? boundText(nativeCommand.trim()) : undefined; const nativeCwd = input?.Cwd ?? input?.WorkingDirectory ?? input?.working_dir ?? input?.workingDir ?? input?.cwd ?? output?.workingDir ?? output?.working_dir; const cwd = nativeCwd?.trim() ? boundText(nativeCwd.trim()) : undefined; const nativeOutput = output?.combinedOutput ?? output?.combined_output; const aggregatedOutput = nativeOutput === undefined ? undefined : boundText(nativeOutput); const exitCode = output?.exitCode ?? output?.exit_code; const imagePath = localImagePath(output?.imagePath); const sanitizedData = sanitizeAntigravityToolPayload(toolCall.data); const data: Record = Predicate.isObject(sanitizedData) ? sanitizedData : {}; const kind = toolCall.kind ?? (command !== undefined ? "execute" : undefined); if (kind !== undefined) data.kind = kind; if (command !== undefined) data.command = command; if (cwd !== undefined) data.cwd = cwd; if (imagePath !== undefined) data.imagePath = imagePath; if (kind === "execute") { data.item = { ...(Predicate.isObject(data.item) ? data.item : {}), ...(command !== undefined ? { command } : {}), ...(cwd !== undefined ? { cwd } : {}), ...(aggregatedOutput !== undefined ? { aggregatedOutput } : {}), ...(exitCode !== undefined ? { exitCode } : {}), }; } return { ...toolCall, ...(kind !== undefined ? { kind } : {}), ...(command !== undefined ? { command } : {}), ...(toolCall.title !== undefined ? { title: boundText(toolCall.title) } : {}), ...(command !== undefined ? { detail: command } : toolCall.detail !== undefined ? { detail: boundText(toolCall.detail) } : {}), data, }; } /** Only commands still running after end_turn become background tasks. */ export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean { return toolCall.kind === "execute" && toolCall.status === "inProgress"; } /** ACP 1.1.1 exposes subagent invocations as ordinary tools, without child IDs or models. */ export function classifyAntigravitySubagentToolCall( toolCall: AcpToolCallState, rawPayload: unknown, ): "subagent" | "mcp" | undefined { if ( (toolCall.kind !== undefined && toolCall.kind !== "other") || (toolCall.title !== "Running start_subagent" && toolCall.title !== "Run start_subagent?") ) return undefined; const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; const meta = Predicate.isObject(update) ? update._meta : undefined; return Predicate.isObject(meta) && meta.is_mcp_tool_call === true ? "mcp" : "subagent"; } /** History sends a completed start before the separate result and its final status. */ export function isAntigravitySubagentReplayStart(rawPayload: unknown): boolean { const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; return ( Predicate.isObject(update) && update.sessionUpdate === "tool_call" && update.status === "completed" && (update.rawOutput === undefined || update.rawOutput === null) ); } export function antigravitySubagentOutput(toolCall: AcpToolCallState): string | undefined { const output = toolCall.data.rawOutput; return typeof output === "string" && output.trim() ? boundText(output.trim()) : undefined; }