| 1 | import type { Action, State } from "./useController"; |
| 2 | import { sessionIdentityStableKey } from "./sessionIdentity"; |
| 3 | import { beginLocalSubmission, canonicalUserConfirmations, settleLocalSubmissions, updateLocalSubmission } from "./localSubmissionState"; |
| 4 | |
| 5 | export function submissionBindingCurrent(current: State | undefined, expected: State): boolean { |
| 6 | return current?.sessionGen === expected.sessionGen && sessionIdentityStableKey(current?.meta) === sessionIdentityStableKey(expected.meta); |
| 7 | } |
| 8 | |
| 9 | export function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDoneAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnUsage" | "turnOutputTokens" | "turnOutputChars" | "turnOutputCharsAtUsage" | "turnOutputEstimated" | "turnModelActiveAt" | "turnModelActiveMs" | "turnCost" | "turnRateBand" | "turnArgChars" | "pendingRequestModelMs"> { |
| 10 | return { |
| 11 | turnStartAt: now, |
| 12 | turnDoneAt: 0, |
| 13 | turnWaitAccumMs: 0, |
| 14 | promptWaitStartedAt: undefined, |
| 15 | turnTokens: 0, |
| 16 | turnTotalTokens: 0, |
| 17 | turnUsage: undefined, |
| 18 | turnOutputTokens: 0, |
| 19 | turnOutputChars: 0, |
| 20 | turnOutputCharsAtUsage: 0, |
| 21 | turnOutputEstimated: false, |
| 22 | turnModelActiveAt: undefined, |
| 23 | turnModelActiveMs: 0, pendingRequestModelMs: undefined, |
| 24 | turnCost: 0, |
| 25 | turnRateBand: undefined, |
| 26 | turnArgChars: 0, |
| 27 | }; |
| 28 | } |
| 29 | |
| 30 | export function confirmPendingUser(s: State, submissionId: string | undefined): State { |
| 31 | if (!submissionId) return s; |
| 32 | const next = s.pendingSubmissionId === submissionId ? { ...s, pendingUser: undefined, pendingSubmissionId: undefined } : s; |
| 33 | if (s.localSubmissions[submissionId]?.status === "failed") return next; |
| 34 | return updateLocalSubmission(next, submissionId, { |
| 35 | status: "accepted", |
| 36 | }); |
| 37 | } |
| 38 | |
| 39 | |
| 40 | export function installTranscriptRecords(s: State, a: Extract<Action, { type: "transcript_records" }>): State { |
| 41 | const updates = new Map(a.projection.items.map(item => [item.id, item])); |
| 42 | const removed = new Set(a.projection.removeIds); |
| 43 | const items = s.items.filter(item => !removed.has(item.id)).map(item => { |
| 44 | const update = updates.get(item.id); |
| 45 | if (item.kind === "tool" && update?.kind === "tool" && update.resultMissing && item.status === "running") { |
| 46 | return { ...update, status: "running" as const, execution: item.execution, startedAt: item.startedAt }; |
| 47 | } |
| 48 | if (item.kind === "assistant" && update?.kind === "assistant" && item.turnFinal && !update.turnFinal) { |
| 49 | return { ...update, turnFinal: true, turnDurationMs: item.turnDurationMs, turnUsage: item.turnUsage, |
| 50 | samplingCount: item.samplingCount, toolCount: item.toolCount }; |
| 51 | } |
| 52 | return update ?? item; |
| 53 | }); |
| 54 | const present = new Set(items.map(item => item.id)); |
| 55 | const positions = new Map(a.projection.items.map((item, index) => [item.id, index])); |
| 56 | for (const item of updates.values()) { |
| 57 | if (present.has(item.id)) continue; |
| 58 | const following = items.findIndex(candidate => (positions.get(candidate.id) ?? -1) > positions.get(item.id)!); |
| 59 | const output = item.kind === "user" && item.turnId |
| 60 | ? items.findIndex(candidate => candidate.kind !== "user" && candidate.turnId === item.turnId) : -1; |
| 61 | const at = following >= 0 && output >= 0 ? Math.min(following, output) : Math.max(following, output); |
| 62 | items.splice(at < 0 ? items.length : at, 0, item); |
| 63 | present.add(item.id); |
| 64 | } |
| 65 | return settleLocalSubmissions({ ...s, items, historyHasOlder: a.projection.hasOlder, historyHasNewer: a.projection.hasNewer }, items, |
| 66 | [...a.confirmedUsers, ...canonicalUserConfirmations(a.projection.items)]); |
| 67 | } |
| 68 | |
| 69 | export function startLocalSubmission(s: State, a: Extract<Action, { type: "user" }>, clock: number): State { |
| 70 | const seq = a.seq !== undefined ? a.seq : s.seq; |
| 71 | const userItemId = `u${seq}`; |
| 72 | const next = { |
| 73 | ...s, |
| 74 | completionSummary: undefined, |
| 75 | seq: seq + 1, |
| 76 | items: s.items.map(item => item.kind==="notice" && item.action==="recover_context" ? {...item,action:undefined} : item), |
| 77 | running: true, |
| 78 | pendingPrompt: false, |
| 79 | cancelRequested: false, |
| 80 | cancellable: true, |
| 81 | ...resetTurnTiming(), |
| 82 | turnLifecycleObservedAt: clock, |
| 83 | // New turn epoch: forget the previous prompt anchor so a genuinely new |
| 84 | // prompt re-anchors freshly instead of inheriting a stale id/time. |
| 85 | promptArrivedAt: undefined, |
| 86 | promptArrivedId: undefined, |
| 87 | pendingUser: a.text, |
| 88 | pendingSubmissionId: a.submissionId, |
| 89 | activeTurnId: s.turnActive ? s.activeTurnId : undefined, |
| 90 | currentAssistant: undefined, |
| 91 | assistantSegmentOrdinal: 0, |
| 92 | live: undefined, |
| 93 | streamAttemptJournal: undefined, |
| 94 | streamInterruptNoticeShown: undefined, |
| 95 | deliveryRecoveryActive: Boolean(a.deliveryRecovery), |
| 96 | discardTurn: false, |
| 97 | }; |
| 98 | return beginLocalSubmission(next, { |
| 99 | submissionId: a.submissionId, |
| 100 | localId: userItemId, |
| 101 | text: a.text, |
| 102 | submitText: a.submitText, |
| 103 | createdAt: Date.now(), |
| 104 | sequence: seq, |
| 105 | anchorItemId: s.historyHasNewer ? undefined : s.items[s.items.length - 1]?.id, |
| 106 | placement: s.historyHasNewer ? "latest" : s.items.length ? "after" : "start", |
| 107 | }); |
| 108 | } |
| 109 |