| 1 | import type { AppBindings } from "./bridge"; |
| 2 | import type { StructuredInvocationSubmit } from "./invocationDisplay"; |
| 3 | import { resolveActiveTurnId } from "./inboxSubmit"; |
| 4 | import { confirmFollowup, followupNotSubmitted, followupSessionKey, pendingFollowups, type PendingFollowup } from "./pendingFollowup"; |
| 5 | |
| 6 | type InboxEnqueueBindings = Pick<AppBindings, "EnqueueInboxFollowup" | "EnqueueInboxFollowupWithInvocations" | "EnqueueInboxSteer" | "EnqueueInboxSteerForTurn" | "EnqueueForAttachmentTarget">; |
| 7 | |
| 8 | // Keep request construction with the lazy submission owner while callers |
| 9 | // capture the session target before crossing the module-loading boundary. |
| 10 | export function enqueueGuidanceForTarget(binding: AppBindings, target: PendingFollowup["target"], tabId: string, text: string, turnId?: string) { |
| 11 | return enqueueTrackedGuidance(binding, { |
| 12 | tabId, target, key: `guidance-${crypto.randomUUID()}`, display: text, submit: text, draft: text, |
| 13 | }, turnId); |
| 14 | } |
| 15 | |
| 16 | // Non-Composer callers share its unresolved-request owner so a lost receipt |
| 17 | // cannot cause a second POST when the user retries guidance. |
| 18 | export async function enqueueTrackedGuidance(binding: AppBindings, request: PendingFollowup, turnId?: string) { |
| 19 | const { target } = request; |
| 20 | const pendingKey = followupSessionKey(target?.sessionPath, target?.hostId, target?.workspace); |
| 21 | const unresolved = pendingKey ? pendingFollowups.get(pendingKey) : undefined; |
| 22 | if (unresolved) { |
| 23 | const receipt = await confirmFollowup(binding, unresolved); |
| 24 | pendingFollowups.clear(pendingKey, unresolved); |
| 25 | if (unresolved.submit === request.submit) return receipt; |
| 26 | // A different instruction still needs its own receipt; acknowledging the |
| 27 | // previous request must never clear a newer draft as if it was delivered. |
| 28 | } |
| 29 | if (pendingKey) pendingFollowups.set(pendingKey, request); |
| 30 | try { |
| 31 | const receipt = await enqueueComposerGuidance(binding, request, false, turnId); |
| 32 | if (receipt?.error) throw new Error(receipt.error); |
| 33 | if (!receipt?.itemId) throw new Error("Follow-up receipt unconfirmed"); |
| 34 | if (pendingKey) pendingFollowups.clear(pendingKey, request); |
| 35 | return receipt; |
| 36 | } catch (error) { |
| 37 | if (pendingKey && followupNotSubmitted(error)) pendingFollowups.clear(pendingKey, request); |
| 38 | throw error; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | export async function enqueueComposerGuidance(binding: AppBindings, request: PendingFollowup, queueOnly: boolean, turnId?: string) { |
| 43 | const { target, structured, tabId, display, submit, key } = request; |
| 44 | // Structured invocations and image submissions require their own turn. |
| 45 | if (queueOnly || structured) { |
| 46 | if (target && !structured?.attachments?.length) { |
| 47 | if (!binding.EnqueueInboxFollowupForTarget) throw new Error("reasonix_error:inbox_not_submitted — target submission unavailable"); |
| 48 | return binding.EnqueueInboxFollowupForTarget(target, display, submit, structured?.invocations ?? [], key); |
| 49 | } |
| 50 | return enqueueInboxGuidance(binding, tabId, display, submit, structured, { idempotency: key }); |
| 51 | } |
| 52 | if (target && !structured) { |
| 53 | // Discovery is read-only. If no turn can be established, preserve the input |
| 54 | // as a follow-up using the original session target and idempotency key. |
| 55 | const followup = () => { |
| 56 | if (!binding.EnqueueInboxFollowupForTarget) throw new Error("reasonix_error:inbox_not_submitted — target submission unavailable"); |
| 57 | return binding.EnqueueInboxFollowupForTarget(target, display, submit, [], key); |
| 58 | }; |
| 59 | const activeTurnId = await resolveActiveTurnId(binding, tabId, turnId).catch(() => undefined); |
| 60 | if (!activeTurnId || !binding.InboxQueueForTarget) return followup(); |
| 61 | const result = await binding.InboxQueueForTarget(target, { kind: "enqueue_steer", text: submit, display, turnId: activeTurnId, idempotencyKey: key }); |
| 62 | // Only explicit unsupported guarantees no mutation. A transport failure or |
| 63 | // absent receipt is uncertain and must use receipt recovery, never resend. |
| 64 | if (result.outcome === "unavailable" && result.reason === "unsupported") return followup(); |
| 65 | if (result.outcome === "unavailable" && result.reason === "session_changed") { |
| 66 | // Remote selection can change after the POST committed. Retain the |
| 67 | // pending request for receipt recovery instead of declaring it unsent. |
| 68 | throw new Error("Follow-up receipt unconfirmed — session_changed"); |
| 69 | } |
| 70 | if (result.receipt?.error) throw new Error(result.receipt.error); |
| 71 | if (!result.receipt?.itemId) throw new Error("Follow-up receipt unconfirmed"); |
| 72 | return result.receipt; |
| 73 | } |
| 74 | return enqueueInboxGuidanceForActiveTurn(binding, tabId, display, submit, structured, turnId, key); |
| 75 | } |
| 76 | |
| 77 | export async function enqueueInboxGuidanceForActiveTurn( |
| 78 | binding: InboxEnqueueBindings & Pick<AppBindings, "ListTabs">, |
| 79 | tabId: string, |
| 80 | display: string, |
| 81 | submit: string, |
| 82 | structured?: StructuredInvocationSubmit, |
| 83 | knownTurnId?: string, |
| 84 | idempotency?: string, |
| 85 | ) { |
| 86 | const turnId = !structured && typeof binding.EnqueueInboxSteerForTurn === "function" |
| 87 | ? await resolveActiveTurnId(binding, tabId, knownTurnId) |
| 88 | : knownTurnId; |
| 89 | return enqueueInboxGuidance(binding, tabId, display, submit, structured, { steer: true, turnId, idempotency }); |
| 90 | } |
| 91 | |
| 92 | export function enqueueInboxGuidance( |
| 93 | binding: InboxEnqueueBindings, |
| 94 | tabId: string, |
| 95 | display: string, |
| 96 | submit: string, |
| 97 | structured?: StructuredInvocationSubmit, |
| 98 | opts?: { steer?: boolean; turnId?: string; idempotency?: string }, |
| 99 | ) { |
| 100 | if (structured) { |
| 101 | if (structured.attachments?.length) { |
| 102 | if (!structured.attachmentTarget || !binding.EnqueueForAttachmentTarget) { |
| 103 | return Promise.reject(new Error("unsupported: attachments-v2")); |
| 104 | } |
| 105 | return binding.EnqueueForAttachmentTarget( |
| 106 | structured.attachmentTarget, |
| 107 | structured.attachmentSubmissionId || opts?.idempotency || `image-${crypto.randomUUID()}`, |
| 108 | structured.input.trim(), |
| 109 | structured.display.trim() || display, |
| 110 | structured.invocations, |
| 111 | structured.attachments, |
| 112 | ); |
| 113 | } |
| 114 | return binding.EnqueueInboxFollowupWithInvocations( |
| 115 | tabId, |
| 116 | structured.display.trim() || display, |
| 117 | structured.input.trim(), |
| 118 | structured.invocations, |
| 119 | opts?.idempotency ?? "", |
| 120 | ); |
| 121 | } |
| 122 | if (opts?.steer && typeof binding.EnqueueInboxSteer === "function") { |
| 123 | if (typeof binding.EnqueueInboxSteerForTurn === "function") { |
| 124 | if (!opts.turnId) return Promise.reject(new Error("active turn id is unavailable; refresh and try again")); |
| 125 | return binding.EnqueueInboxSteerForTurn(tabId, opts.turnId, display, submit || display, opts.idempotency ?? ""); |
| 126 | } |
| 127 | return binding.EnqueueInboxSteer(tabId, display, submit || display, opts.idempotency ?? ""); |
| 128 | } |
| 129 | return binding.EnqueueInboxFollowup(tabId, display, submit || display, opts?.idempotency ?? ""); |
| 130 | } |
| 131 |