| 1 | import type { AppBindings } from "./bridge"; |
| 2 | import { asArray } from "./array"; |
| 3 | import { removeEmptyAssistantItems } from "./assistantItems"; |
| 4 | import type { Item, State } from "./useController"; |
| 5 | import { removeLocalSubmission, updateLocalSubmission } from "./localSubmissionState"; |
| 6 | import type { ManagementReceipt } from "./turnSubmit"; |
| 7 | import { t } from "./i18n"; |
| 8 | import { isTerminalSessionOperation } from "./sessionMaintenanceOperation"; |
| 9 | |
| 10 | export function reduceSubmitFailure( |
| 11 | state: State, |
| 12 | submissionId: string, |
| 13 | error: string, |
| 14 | conservative: boolean, |
| 15 | observedAt: number, |
| 16 | ): State { |
| 17 | if (!state.localSubmissions[submissionId] || state.localSubmissions[submissionId].settled) return state; |
| 18 | const ownsRequest = state.pendingSubmissionId === submissionId; |
| 19 | const ownsTurn = Boolean(state.activeTurnId && state.localSubmissions[submissionId].turnId === state.activeTurnId); |
| 20 | if (!ownsRequest && (state.pendingSubmissionId || !ownsTurn)) { |
| 21 | return updateLocalSubmission(state, submissionId, { status: "failed" }); |
| 22 | } |
| 23 | const next = updateLocalSubmission({ |
| 24 | ...state, |
| 25 | pendingUser: undefined, |
| 26 | pendingSubmissionId: undefined, |
| 27 | deliveryRecoveryActive: false, |
| 28 | cancelRequested: false, |
| 29 | seq: state.seq + 1, |
| 30 | items: [...(state.transcriptProtocol === 2 ? state.items : removeEmptyAssistantItems(state.items)), { kind: "notice", id: `n${state.seq}`, local: true, level: "warn", text: error } as Item], |
| 31 | }, submissionId, { status: "failed" }); |
| 32 | return { |
| 33 | ...next, |
| 34 | running: conservative, |
| 35 | turnActive: conservative, |
| 36 | pendingPrompt: conservative && Boolean(state.approval || state.ask || state.mcpInteraction), |
| 37 | cancellable: conservative, |
| 38 | ...(conservative ? {} : { |
| 39 | activeTurnId: undefined, |
| 40 | currentAssistant: undefined, |
| 41 | assistantSegmentOrdinal: 0, |
| 42 | live: undefined, |
| 43 | streamAttemptJournal: undefined, |
| 44 | turnLifecycleObservedAt: observedAt, |
| 45 | }), |
| 46 | }; |
| 47 | } |
| 48 | |
| 49 | export function reduceSubmitQueued(state: State, submissionId: string, observedAt: number): State { |
| 50 | const ownsRequest = state.pendingSubmissionId === submissionId; |
| 51 | if (!ownsRequest) return removeLocalSubmission(state, submissionId); |
| 52 | // The message lives in the durable queue now; retract the optimistic bubble |
| 53 | // so it cannot duplicate the record the queued turn will install later. |
| 54 | return removeLocalSubmission({ |
| 55 | ...state, |
| 56 | pendingUser: undefined, |
| 57 | pendingSubmissionId: undefined, |
| 58 | running: false, |
| 59 | turnActive: false, |
| 60 | pendingPrompt: false, |
| 61 | cancelRequested: false, |
| 62 | cancellable: false, |
| 63 | activeTurnId: undefined, |
| 64 | currentAssistant: undefined, |
| 65 | assistantSegmentOrdinal: 0, |
| 66 | live: undefined, |
| 67 | streamAttemptJournal: undefined, |
| 68 | deliveryRecoveryActive: false, |
| 69 | turnLifecycleObservedAt: observedAt, |
| 70 | }, submissionId); |
| 71 | } |
| 72 | |
| 73 | export function reduceManagementConfirmation(state: State, submissionId: string, observedAt: number, receipt?: ManagementReceipt): State { |
| 74 | const ownsRequest = state.pendingSubmissionId === submissionId; |
| 75 | // Compact requests do not create optimistic chat turns. Only legacy |
| 76 | // management submissions with their own echo need conversational cleanup. |
| 77 | const next = removeLocalSubmission(ownsRequest ? { |
| 78 | ...state, |
| 79 | pendingUser: undefined, |
| 80 | pendingSubmissionId: undefined, |
| 81 | running: false, |
| 82 | turnActive: false, |
| 83 | pendingPrompt: false, |
| 84 | cancelRequested: false, |
| 85 | cancellable: false, |
| 86 | activeTurnId: undefined, |
| 87 | currentAssistant: undefined, |
| 88 | assistantSegmentOrdinal: 0, |
| 89 | live: undefined, |
| 90 | streamAttemptJournal: undefined, |
| 91 | deliveryRecoveryActive: false, |
| 92 | turnLifecycleObservedAt: observedAt, |
| 93 | } : state, submissionId); |
| 94 | if (!receipt?.errorCode) return next; |
| 95 | const busy = receipt.errorCode === "maintenance_busy"; |
| 96 | // An admission response can arrive after the operation's durable terminal event. |
| 97 | if (busy && next.items.some(item => item.kind === "compaction" && item.operationId === receipt.operationId |
| 98 | && isTerminalSessionOperation(item.status))) return next; |
| 99 | const text = t(busy ? "compaction.alreadyRunning" : receipt.errorCode === "maintenance_recovery_required" |
| 100 | ? "compaction.recoveryRequired" : "compaction.unavailable"); |
| 101 | const id = `management:${receipt.operationId || submissionId}:${receipt.errorCode}`; |
| 102 | return { ...next, items: [...next.items.filter(item => item.id !== id), |
| 103 | { kind: "notice", id, local: true, level: busy ? "info" : "warn", text } as Item] }; |
| 104 | } |
| 105 | |
| 106 | export async function findTabAfterSubmitFailure( |
| 107 | binding: Pick<AppBindings, "ListTabs">, |
| 108 | tabId: string, |
| 109 | delays: readonly number[], |
| 110 | clock: () => number, |
| 111 | ) { |
| 112 | for (const delay of delays) { |
| 113 | if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); |
| 114 | try { |
| 115 | // Fence at read start, so a delayed response cannot override a turn or |
| 116 | // prompt observed while it was in flight. Preserve a sub-tick advance |
| 117 | // for synchronous bridges called in the initiating event's clock tick. |
| 118 | const snapshotAt = clock() + 0.001; |
| 119 | const tab = asArray(await binding.ListTabs()).find((candidate) => candidate.id === tabId); |
| 120 | return [tab, snapshotAt] as const; |
| 121 | } catch { |
| 122 | // The caller's stale-turn watchdog remains the long-tail backstop. |
| 123 | } |
| 124 | } |
| 125 | return undefined; |
| 126 | } |
| 127 | |
| 128 | export function reduceSubmitUnknown(s: State, submissionId: string, error: string): State { |
| 129 | const local = s.localSubmissions[submissionId]; |
| 130 | if (!local || local.settled || local.status === "failed") return s; |
| 131 | const ownsRequest = s.pendingSubmissionId === submissionId; |
| 132 | const ownsTurn = !s.pendingSubmissionId && s.activeTurnId && local.turnId === s.activeTurnId; |
| 133 | return updateLocalSubmission(ownsRequest || ownsTurn ? { |
| 134 | ...s, transcriptConnection: "disconnected", transcriptConnectionError: error, |
| 135 | } : s, submissionId, { status: "unknown" }); |
| 136 | } |
| 137 |