import { Type } from "@sinclair/typebox"; import type { ExtensionAPI, ExtensionContext } from "../_shared/pi-api.js"; import { getProjectRoot, textResult } from "../_shared/pi-api.js"; import { validateParams } from "../_shared/validation.js"; import { applyPreview, discardPreview, getLatestPendingPreview, getPreview } from "../_shared/ast-engine.js"; import { emitDevEvent } from "../_shared/event-bus.js"; const AstApplyParams = Type.Object({ action: Type.Union([Type.Literal("apply"), Type.Literal("discard")], { description: "Preview lifecycle action" }), previewId: Type.String({ description: "Preview id returned by ast_edit" }), reason: Type.Optional(Type.String({ description: "Why applying or discarding the preview", maxLength: 500 })), }); const ResolveParams = Type.Object({ action: Type.Union([Type.Literal("apply"), Type.Literal("discard")], { description: "Whether to apply or discard the pending preview" }), reason: Type.String({ description: "Why applying or discarding the pending preview", maxLength: 500 }), extra: Type.Optional(Type.Object({ previewId: Type.Optional(Type.String({ description: "Preview id returned by ast_edit. Optional; resolve defaults to the latest pending AST preview." })), }, { additionalProperties: true, description: "Free-form resolve metadata" })), }); export default function astApplyTool(pi: ExtensionAPI): void { pi.registerTool({ name: "ast_apply", description: "Legacy alias for resolve over ast_edit previews. Prefer resolve for new AST preview finalization.", parameters: AstApplyParams, async execute(_toolCallId, params, _signal, _update, ctx) { const valid = validateParams(AstApplyParams, params); if (!valid.ok) return valid.result; return finalizePreview(valid.value.previewId, valid.value.action, valid.value.reason ?? "", ctx, "ast_apply"); }, }); pi.registerTool({ name: "resolve", description: "Resolve a pending ast_edit preview by applying or discarding it.", parameters: ResolveParams, async execute(_toolCallId, params, _signal, _update, ctx) { const valid = validateParams(ResolveParams, params); if (!valid.ok) return valid.result; const previewId = valid.value.extra?.previewId ?? getLatestPendingPreview(getProjectRoot(ctx))?.id; if (!previewId) return { isError: true, content: [{ type: "text", text: "No pending action to resolve. Nothing to apply or discard." }] }; return finalizePreview(previewId, valid.value.action, valid.value.reason, ctx, "resolve"); }, }); } async function finalizePreview( previewId: string, action: "apply" | "discard", reason: string, ctx: ExtensionContext, sourceToolName: "resolve" | "ast_apply", ) { const preview = getPreview(previewId); if (!preview) return { isError: true, content: [{ type: "text" as const, text: `Unknown preview: ${previewId}` }] }; if (action === "discard") { discardPreview(previewId); emitDevEvent(`${sourceToolName}:discard`, { previewId, reason }); return textResult(`Discarded ${previewId}`, resolveDetails(action, reason, previewId, sourceToolName)); } const result = await applyPreview(previewId, getProjectRoot(ctx)); if (result.stale.length) { emitDevEvent(`${sourceToolName}:stale`, { previewId, files: result.stale.length }); return { isError: true, content: [{ type: "text" as const, text: `Preview is stale; refusing apply:\n${result.stale.join("\n")}` }], details: { ...resolveDetails(action, reason, previewId, sourceToolName), stale: result.stale }, }; } emitDevEvent(`${sourceToolName}:apply`, { previewId, files: result.applied }); return textResult(`Applied ${previewId} to ${result.applied} files`, { ...resolveDetails(action, reason, previewId, sourceToolName), filesApplied: result.applied, }); } function resolveDetails(action: "apply" | "discard", reason: string, previewId: string, sourceToolName: "resolve" | "ast_apply") { return { action, reason, sourceToolName, label: `ast_edit preview ${previewId}`, extra: { previewId }, sourceResultDetails: { previewId }, }; }