| 1 | import { useCallback, useMemo } from "react"; |
| 2 | import { useCommittedCommand } from "../lib/useCommittedCommand"; |
| 3 | import { CommandCancelled } from "../lib/commandOutcome"; |
| 4 | import { hasSessionGeneration } from "../lib/sessionIdentity"; |
| 5 | import type { PromptDiagnosticTarget } from "../lib/promptSubmissionDiagnostics"; |
| 6 | import type { QuestionAnswer, ToolApprovalMode, WireApproval, WireAsk, WireMCPInteraction } from "../lib/types"; |
| 7 | import { executeSessionPrompt, type PromptPorts, type PromptRequest, type SessionPromptKind } from "./sessionPromptExecutor"; |
| 8 | import { interactionInstanceKey, type InteractionKind, type InteractionTarget } from "../lib/interactionTarget"; |
| 9 | import type { SessionRef } from "../lib/sessionRef"; |
| 10 | import type { MCPInteractionAction, RecoveryAction } from "./sessionActionOwner"; |
| 11 | import type { SessionResource, useSessionOperations } from "./useSessionOperations"; |
| 12 | |
| 13 | type Input = { |
| 14 | target: SessionResource; |
| 15 | sessionGeneration?: number; |
| 16 | session?: SessionRef | null; |
| 17 | approval?: WireApproval; |
| 18 | question?: WireAsk; |
| 19 | mcpInteraction?: WireMCPInteraction; |
| 20 | remote: boolean; |
| 21 | goal: string; |
| 22 | toolApprovalMode: ToolApprovalMode; |
| 23 | ports: PromptPorts; |
| 24 | operations: ReturnType<typeof useSessionOperations>; |
| 25 | reportError: (error: unknown) => void; |
| 26 | }; |
| 27 | |
| 28 | // Diagnostics must neither block authorization nor turn a logging failure into |
| 29 | // a failed user action. The recorder is shared with session diagnostic exports. |
| 30 | function recordOutcome(target: PromptDiagnosticTarget, status: string, error?: unknown) { |
| 31 | void import("../lib/promptSubmissionDiagnostics").then(({ notePromptSubmission, promptFailureClass }) => { |
| 32 | notePromptSubmission(target, "command", error === undefined ? status : `${status}:${promptFailureClass(error)}`); |
| 33 | }).catch(() => {}); |
| 34 | } |
| 35 | |
| 36 | export function useSessionPromptCommands(input: Input) { |
| 37 | const { target: sessionTarget, session, sessionGeneration, operations, ports } = input; |
| 38 | const makeTarget = useCallback((prompt: WireApproval | WireAsk | WireMCPInteraction | undefined, promptKind: SessionPromptKind): InteractionTarget | undefined => { |
| 39 | if (!prompt?.id || !sessionTarget.tabId || !session?.sessionId || !hasSessionGeneration(sessionGeneration)) return undefined; |
| 40 | let kind: InteractionKind; |
| 41 | if (promptKind === "mcpInteraction") kind = "mcp"; |
| 42 | else if (promptKind === "ask") kind = "ask"; |
| 43 | else { |
| 44 | const approval = prompt as WireApproval; |
| 45 | kind = approval.kind === "recovery" || approval.recovery |
| 46 | ? "recovery" : approval.tool === "exit_plan_mode" ? "plan" : "approval"; |
| 47 | } |
| 48 | const base = { |
| 49 | ...sessionTarget, |
| 50 | hostId: session.hostId, |
| 51 | sessionId: session.sessionId, |
| 52 | sessionGeneration, |
| 53 | promptId: prompt.id, |
| 54 | turnId: prompt.turnId, |
| 55 | runtimeEpoch: prompt.runtimeEpoch, |
| 56 | kind, |
| 57 | requestGeneration: "generation" in prompt ? prompt.generation : undefined, |
| 58 | permissionRevision: "permissionRevision" in prompt ? prompt.permissionRevision : undefined, |
| 59 | }; |
| 60 | return { ...base, instanceKey: interactionInstanceKey(base) }; |
| 61 | }, [sessionTarget, session, sessionGeneration]); |
| 62 | const approvalTarget = makeTarget(input.approval, "approval"); |
| 63 | const questionTarget = makeTarget(input.question, "ask"); |
| 64 | const mcpTarget = makeTarget(input.mcpInteraction, "mcpInteraction"); |
| 65 | const run = useCallback(async (target: InteractionTarget | undefined, promptKind: SessionPromptKind, request: PromptRequest) => { |
| 66 | const diagnosticTarget = target ?? { ...sessionTarget, sessionId: session?.sessionId, sessionGeneration }; |
| 67 | recordOutcome(diagnosticTarget, "started"); |
| 68 | if (!target) { |
| 69 | recordOutcome(diagnosticTarget, "not-ready"); |
| 70 | throw new CommandCancelled("not-ready"); |
| 71 | } |
| 72 | const result = await operations( |
| 73 | { tabId: target.tabId, sessionKey: target.sessionKey }, |
| 74 | `prompt:${promptKind}`, |
| 75 | { target, promptKind, request, ports }, |
| 76 | executeSessionPrompt, |
| 77 | ); |
| 78 | recordOutcome(diagnosticTarget, result.status === "cancelled" ? result.reason : result.status, |
| 79 | result.status === "failed" ? result.error : undefined); |
| 80 | if (result.status === "failed") throw result.error; |
| 81 | if (result.status === "cancelled") throw new CommandCancelled(result.reason); |
| 82 | }, [operations, ports, sessionTarget, session, sessionGeneration]); |
| 83 | const plan = useCallback((action: "start_execution" | "revise_plan" | "exit_plan", revision?: string) => run(approvalTarget, "approval", { |
| 84 | kind: "plan", action, leavePlanMode: action !== "revise_plan", remote: input.remote, |
| 85 | goal: input.goal, toolApprovalMode: input.toolApprovalMode, revision, |
| 86 | }), [approvalTarget, input.remote, input.goal, input.toolApprovalMode, run]); |
| 87 | const report = useCommittedCommand(input.reportError); |
| 88 | const handleApprovalAnswer = useCallback((allow: boolean, session: boolean, persist: boolean) => ( |
| 89 | input.approval?.tool === "exit_plan_mode" |
| 90 | ? plan(allow ? "start_execution" : "revise_plan") |
| 91 | : run(approvalTarget, "approval", { kind: "approval", allow, session, persist }) |
| 92 | ), [approvalTarget, input.approval?.tool, plan, run]); |
| 93 | const handleRecoveryAnswer = useCallback((action: RecoveryAction, feedback = "") => { |
| 94 | return run(approvalTarget, "approval", { kind: "recovery", action, feedback }); |
| 95 | }, [approvalTarget, run]); |
| 96 | const handleRevisePlan = useCallback((revision: string) => plan("revise_plan", revision), [plan]); |
| 97 | const handleExitPlan = useCallback(() => plan("exit_plan"), [plan]); |
| 98 | const handleQuestionAnswer = useCallback((_id: string, answers: QuestionAnswer[]) => run(questionTarget, "ask", { kind: "question", answers }), [questionTarget, run]); |
| 99 | const handleQuestionDismiss = useCallback(() => run(questionTarget, "ask", { kind: "question", answers: [] }), [questionTarget, run]); |
| 100 | const handleMCPAnswer = useCallback((_id: string, action: MCPInteractionAction, content?: Record<string, unknown>) => { |
| 101 | void run(mcpTarget, "mcpInteraction", { kind: "mcp", action, content }).catch(report); |
| 102 | }, [mcpTarget, report, run]); |
| 103 | return useMemo(() => ({ |
| 104 | approvalTarget, questionTarget, mcpTarget, |
| 105 | handleApprovalAnswer, handleRecoveryAnswer, handleRevisePlan, handleExitPlan, |
| 106 | handleQuestionAnswer, handleQuestionDismiss, handleMCPAnswer, |
| 107 | }), [approvalTarget, questionTarget, mcpTarget, handleApprovalAnswer, handleRecoveryAnswer, handleRevisePlan, handleExitPlan, handleQuestionAnswer, handleQuestionDismiss, handleMCPAnswer]); |
| 108 | } |
| 109 |