| 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 | |
| 7 | export function reduceSubmitFailure( |
| 8 | state: State, |
| 9 | submissionId: string, |
| 10 | error: string, |
| 11 | conservative: boolean, |
| 12 | observedAt: number, |
| 13 | ): State { |
| 14 | if (!state.localSubmissions[submissionId] || state.localSubmissions[submissionId].settled) return state; |
| 15 | const ownsRequest = state.pendingSubmissionId === submissionId; |
| 16 | const ownsTurn = Boolean(state.activeTurnId && state.localSubmissions[submissionId].turnId === state.activeTurnId); |
| 17 | if (!ownsRequest && (state.pendingSubmissionId || !ownsTurn)) { |
| 18 | return updateLocalSubmission(state, submissionId, { status: "failed" }); |
| 19 | } |
| 20 | const next = updateLocalSubmission({ |
| 21 | ...state, |
| 22 | pendingUser: undefined, |
| 23 | pendingSubmissionId: undefined, |
| 24 | deliveryRecoveryActive: false, |
| 25 | cancelRequested: false, |
| 26 | seq: state.seq + 1, |
| 27 | items: [...(state.transcriptProtocol === 2 ? state.items : removeEmptyAssistantItems(state.items)), { kind: "notice", id: `n${state.seq}`, local: true, level: "warn", text: error } as Item], |
| 28 | }, submissionId, { status: "failed" }); |
| 29 | return { |
| 30 | ...next, |
| 31 | running: conservative, |
| 32 | turnActive: conservative, |
| 33 | pendingPrompt: conservative && Boolean(state.approval || state.ask || state.mcpInteraction), |
| 34 | cancellable: conservative, |
| 35 | ...(conservative ? {} : { |
| 36 | activeTurnId: undefined, |
| 37 | currentAssistant: undefined, |
| 38 | assistantSegmentOrdinal: 0, |
| 39 | live: undefined, |
| 40 | streamAttemptJournal: undefined, |
| 41 | turnLifecycleObservedAt: observedAt, |
| 42 | }), |
| 43 | }; |
| 44 | } |
| 45 | |
| 46 | export function reduceManagementConfirmation(state: State, submissionId: string, observedAt: number): State { |
| 47 | if (state.pendingSubmissionId !== submissionId) return removeLocalSubmission(state, submissionId); |
| 48 | return removeLocalSubmission({ |
| 49 | ...state, |
| 50 | pendingUser: undefined, |
| 51 | pendingSubmissionId: undefined, |
| 52 | running: false, |
| 53 | turnActive: false, |
| 54 | pendingPrompt: false, |
| 55 | cancelRequested: false, |
| 56 | cancellable: false, |
| 57 | activeTurnId: undefined, |
| 58 | currentAssistant: undefined, |
| 59 | assistantSegmentOrdinal: 0, |
| 60 | live: undefined, |
| 61 | streamAttemptJournal: undefined, |
| 62 | deliveryRecoveryActive: false, |
| 63 | turnLifecycleObservedAt: observedAt, |
| 64 | }, submissionId); |
| 65 | } |
| 66 | |
| 67 | export async function findTabAfterSubmitFailure( |
| 68 | binding: Pick<AppBindings, "ListTabs">, |
| 69 | tabId: string, |
| 70 | delays: readonly number[], |
| 71 | clock: () => number, |
| 72 | ) { |
| 73 | for (const delay of delays) { |
| 74 | if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); |
| 75 | try { |
| 76 | // Fence at read start, so a delayed response cannot override a turn or |
| 77 | // prompt observed while it was in flight. Preserve a sub-tick advance |
| 78 | // for synchronous bridges called in the initiating event's clock tick. |
| 79 | const snapshotAt = clock() + 0.001; |
| 80 | const tab = asArray(await binding.ListTabs()).find((candidate) => candidate.id === tabId); |
| 81 | return [tab, snapshotAt] as const; |
| 82 | } catch { |
| 83 | // The caller's stale-turn watchdog remains the long-tail backstop. |
| 84 | } |
| 85 | } |
| 86 | return undefined; |
| 87 | } |
| 88 |