import type { ComputerStatus, ProductEvent, Run, RunStatus, ThreadMessage, ThreadMessagePage, ThreadSnapshot, } from "@rakazo/contracts"; import { isActive, isRunTerminalEvent, mergeThreadHistory, prependThreadHistoryPage, progressMessageId, reduceLiveMessageBlocks, runFailureError, subagentBlockFromPayload, takeLiveMessage, updateCloudAgentMessages, upsertMessageById, } from "@rakazo/core"; const runTriggers = new Set([ "user", "routine", "resume", "follow_up", "spawn", "skill", "bot_message", "webhook", "messaging", "cloud_agent", "delegation", "media", ]); function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run { const trigger = event.payload.trigger; return { id: event.runId ?? previous?.id ?? event.id, botId: event.botId, threadId: event.threadId, taskId: previous?.taskId ?? event.runId ?? event.id, status: "running", trigger: typeof trigger === "string" && runTriggers.has(trigger as Run["trigger"]) ? (trigger as Run["trigger"]) : (previous?.trigger ?? "user"), routineId: typeof event.payload.routineId === "string" ? event.payload.routineId : (previous?.routineId ?? null), modelProvider: previous?.modelProvider ?? null, modelId: previous?.modelId ?? null, error: null, startedAt: previous?.startedAt ?? event.createdAt, completedAt: null, createdAt: previous?.createdAt ?? event.createdAt, }; } const computerStates: ReadonlySet = new Set([ "stopped", "booting", "running", "suspended", "error", ]); export function activeThreadRuns( snapshot: ThreadSnapshot | null, ): NonNullable { return snapshot?.activeRuns ?? (snapshot?.run ? [snapshot.run] : []); } /** * Reflect a committed direct-message send before its follow-up snapshot arrives. * * threads.send returns only after the message and run are durable. Keeping that * receipt prevents a transient snapshot/SSE interruption from showing a stored * user bubble with no working state. A matching live run always wins, and the * next durable event or refresh still supplies the authoritative status. */ export function applyThreadSendReceipt( snapshot: ThreadSnapshot | null, receipt: { botId: string; runId: string; taskId: string; createdAt?: string }, terminalRunIds: ReadonlySet = new Set(), ): ThreadSnapshot | null { if ( !snapshot || snapshot.groupId || snapshot.botId !== receipt.botId || snapshot.run?.id === receipt.runId || terminalRunIds.has(receipt.runId) ) { return snapshot; } const currentRuns = activeThreadRuns(snapshot); if (currentRuns.some((run) => isActive(run.status as RunStatus))) return snapshot; const createdAt = receipt.createdAt ?? new Date().toISOString(); const run: Run = { id: receipt.runId, botId: receipt.botId, threadId: snapshot.threadId, taskId: receipt.taskId, status: "queued", trigger: "user", routineId: null, modelProvider: null, modelId: null, error: null, startedAt: null, completedAt: null, createdAt, }; return { ...snapshot, run, activeRuns: [run] }; } /** Reason the newest run stopped, until the reader dismisses that run's failure. */ export function threadRunError( snapshot: ThreadSnapshot | null, dismissedRunIds?: ReadonlySet, ): string | null { const run = snapshot?.run; if (run?.status !== "failed" || dismissedRunIds?.has(run.id)) return null; return run.error ?? null; } export function clearActiveThreadRuns(snapshot: ThreadSnapshot): ThreadSnapshot { const runIds = new Set(activeThreadRuns(snapshot).map((run) => run.id)); const computer = snapshot.computer?.busyBotName ? { ...snapshot.computer, busyBotName: null } : snapshot.computer; return { ...snapshot, run: null, activeRuns: [], messages: snapshot.messages.filter( (message) => !message.runId || !runIds.has(message.runId) || !message.id.startsWith("progress:"), ), computer, }; } export function mergeThreadSnapshot( prev: ThreadSnapshot | null, next: ThreadSnapshot, preserveLoadedHistory = false, ): ThreadSnapshot { // A threads.get started before SSE caught up must not wipe newer live state // (e.g. ask cards applied after send's post-refresh request was already in flight). if (prev && prev.threadId === next.threadId && prev.cursor > next.cursor) return prev; return mergeThreadHistory(prev, next, preserveLoadedHistory); } /** * Apply a threads.get refresh without clobbering newer event-sourced takeover state. * * A refresh that started earlier can still return running+busyBotName after the client * already applied waiting_takeover. Cursor comparisons only apply within the same thread. * Stop clears run/busy optimistically in the shell because it has no terminal event; an * older-cursor refresh must keep that cleared local state (see Shell stopRun). */ export function reconcileRefreshedThread( prev: ThreadSnapshot | null, snap: ThreadSnapshot, prevComputer: ComputerStatus | null, preserveLoadedHistory = false, ): { snapshot: ThreadSnapshot; computer: ComputerStatus | null } { const sameThread = Boolean(prev && prev.threadId === snap.threadId); if (sameThread && prev && snap.cursor < prev.cursor) { // A subscription can advance the cursor with progress before the send-triggered refresh // returns the new run record. Hydrate that matching run without rolling the transcript back. // Optimistic stop removes the matching progress message, so an older refresh cannot revive it. const refreshedRun = snap.run; const hasMatchingLiveProgress = Boolean( refreshedRun && prev.messages.some( (message) => message.runId === refreshedRun.id && message.id === `progress:${refreshedRun.id}`, ), ); if (!prev.run && refreshedRun && hasMatchingLiveProgress) { return { snapshot: { ...prev, run: refreshedRun, activeRuns: snap.activeRuns, computer: snap.computer, }, computer: snap.computer ?? null, }; } // Progress can advance the thread cursor while embedded computer status from threads.get // is still useful — but only while a live run remains. Preserve event-sourced // waiting_takeover clears and optimistic stop clears. const preserveLocalComputer = prev.run?.status === "waiting_takeover" || !prev.run || !isActive(prev.run.status as RunStatus); return { snapshot: prev, computer: preserveLocalComputer ? prevComputer : (snap.computer ?? null), }; } let snapshot = mergeThreadSnapshot(prev, snap, preserveLoadedHistory); let computer = snap.computer ?? null; const localWaiting = sameThread && prev?.run?.status === "waiting_takeover" && snapshot.run?.id === prev.run.id && snapshot.run.status !== "waiting_takeover" && isActive(snapshot.run.status as RunStatus); if (localWaiting && snapshot.run) { const runId = snapshot.run.id; snapshot = { ...snapshot, run: { ...snapshot.run, status: "waiting_takeover" }, activeRuns: snapshot.activeRuns?.map((run) => run.id === runId ? { ...run, status: "waiting_takeover" } : run, ), }; if (computer?.busyBotName) computer = { ...computer, busyBotName: null }; } else if (snapshot.run?.status === "waiting_takeover" && computer?.busyBotName) { computer = { ...computer, busyBotName: null }; } return { snapshot, computer }; } export function prependThreadMessagePage( prev: ThreadSnapshot | null, page: ThreadMessagePage, ): ThreadSnapshot | null { return prependThreadHistoryPage(prev, page); } export function isThreadSnapshotEvent(event: ProductEvent): boolean { return ( event.type === "thread.cleared" || event.type === "thread.progress" || event.type === "thread.subagent" || event.type === "thread.cloud_agent" || event.type === "agent.tool.called" || event.type === "agent.tool.completed" || event.type === "thread.computer" || event.type === "thread.message.created" || event.type === "thread.message.updated" || event.type === "thread.message.reaction" || event.type === "run.started" || event.type === "run.waiting_input" || event.type === "computer.takeover.requested" || isRunTerminalEvent(event) ); } export function reduceThreadSnapshot( prev: ThreadSnapshot | null, event: ProductEvent, ): ThreadSnapshot | null { if (!prev) return prev; if (event.type === "thread.cleared") { return { ...prev, cursor: event.seq, messages: [], olderCursor: null, run: null, activeRuns: [], }; } if (event.type === "run.started") { if (!event.runId) { return { ...prev, cursor: event.seq, members: updateMemberStatus(prev.members, event.botId, "running"), }; } const previousRun = prev.activeRuns?.find((candidate) => candidate.id === event.runId) ?? (prev.run?.id === event.runId ? prev.run : undefined); const run = runFromStartedEvent(event, previousRun); const without = (prev.activeRuns ?? (prev.run ? [prev.run] : [])).filter( (candidate) => candidate.id !== run.id, ); // Bot threads keep a single primary run; groups accumulate concurrent member runs. const activeRuns = prev.groupId ? [...without, run] : [run]; return { ...prev, cursor: event.seq, members: updateMemberStatus(prev.members, event.botId, "running"), // A group failure lives only in run; keep it until dismiss so a late member start // cannot wipe the banner (activeRuns still tracks the new work). run: prev.groupId && prev.run?.status === "failed" && prev.run.id !== run.id ? prev.run : run, activeRuns, }; } if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") { const status = event.type === "run.waiting_input" ? "waiting_input" : "waiting_takeover"; const runId = event.runId; const knownInRun = Boolean(runId && prev.run?.id === runId); const knownInActive = Boolean( runId && prev.activeRuns?.some((candidate) => candidate.id === runId), ); // Peer bot_message runs are omitted from snapshots while busy; the first wait // event is how an open thread learns they need ask/takeover UI. const needsInsert = Boolean(runId) && !knownInRun && !knownInActive; const runChanged = Boolean(knownInRun && prev.run && prev.run.status !== status); const activeRunChanged = Boolean( knownInActive && prev.activeRuns?.some((candidate) => candidate.id === runId && candidate.status !== status), ); const members = updateMemberStatus(prev.members, event.botId, status); // Ask pauses delete progress events server-side; drop the live bubble so a missed // message.created cannot leave "working…" stuck next to waiting_input. const liveId = progressMessageId(event); const messages = event.type === "run.waiting_input" && prev.messages.some((message) => message.id === liveId) ? prev.messages.filter((message) => message.id !== liveId) : prev.messages; if ( !runChanged && !activeRunChanged && !needsInsert && members === prev.members && messages === prev.messages ) { return prev; } if (needsInsert && runId) { const waitingRun: Run = { id: runId, botId: event.botId, threadId: event.threadId, taskId: runId, status, trigger: "bot_message", routineId: null, modelProvider: null, modelId: null, error: null, startedAt: event.createdAt, completedAt: null, createdAt: event.createdAt, }; const baseActive = prev.activeRuns ?? (prev.run ? [prev.run] : []); const activeRuns = [...baseActive.filter((candidate) => candidate.id !== runId), waitingRun]; const promoteWaiting = !prev.run || (prev.run.status !== "waiting_input" && prev.run.status !== "waiting_takeover"); return { ...prev, cursor: event.seq, members, messages, run: promoteWaiting ? waitingRun : prev.run, activeRuns, }; } return { ...prev, cursor: event.seq, members, messages, run: runChanged && prev.run ? { ...prev.run, status } : prev.run, activeRuns: activeRunChanged ? prev.activeRuns?.map((candidate) => candidate.id === runId ? { ...candidate, status } : candidate, ) : prev.activeRuns, }; } if (isRunTerminalEvent(event)) { const activeRuns = prev.activeRuns?.filter((candidate) => candidate.id !== event.runId); const nextMemberRun = activeRuns?.find((candidate) => candidate.botId === event.botId); const failure = runFailureError(event); const primaryEnded = prev.run?.id === event.runId ? prev.run : null; // In a group the failing run may be a member run rather than the displayed one, so look // it up in activeRuns as well or its error would be dropped with it. const endedRun = primaryEnded ?? prev.activeRuns?.find((candidate) => candidate.id === event.runId) ?? null; return { ...prev, cursor: event.seq, messages: prev.messages.filter((message) => message.id !== progressMessageId(event)), members: updateMemberStatus(prev.members, event.botId, nextMemberRun?.status ?? "idle"), // A failed run stays in run (activeRuns already excludes it) so the transcript can say // why it stopped, matching what threads.get returns on the next load. run: endedRun && failure ? { ...endedRun, status: "failed", error: failure } : primaryEnded ? (activeRuns?.[0] ?? null) : prev.run, activeRuns, }; } if (event.type === "thread.progress") { const liveId = progressMessageId(event); const { previous, remaining } = takeLiveMessage(prev.messages, liveId); const blocks = reduceLiveMessageBlocks(previous?.blocks ?? [], { type: "progress", payload: event.payload, }); const streaming: ThreadMessage = { id: liveId, threadId: event.threadId, seq: event.seq, role: "bot", blocks, botId: event.botId, runId: event.runId, createdAt: event.createdAt, }; return { ...prev, cursor: event.seq, messages: [...remaining, streaming] }; } if (event.type === "agent.tool.called") { const liveId = progressMessageId(event); const { previous, remaining } = takeLiveMessage(prev.messages, liveId); const blocks = reduceLiveMessageBlocks(previous?.blocks ?? [], { type: "tool", name: String(event.payload.name ?? ""), }); const next: ThreadMessage = { id: liveId, threadId: event.threadId, seq: event.seq, role: "bot", blocks, botId: event.botId, runId: event.runId, createdAt: event.createdAt, }; return { ...prev, cursor: event.seq, messages: [...remaining, next] }; } if (event.type === "agent.tool.completed" || event.type === "thread.computer") { return { ...prev, cursor: event.seq }; } if (event.type === "thread.subagent") { const block = subagentBlockFromPayload(event.payload); const next: ThreadMessage = { id: `subagent:${block.agentId}`, threadId: event.threadId, seq: event.seq, role: "bot", blocks: [block], botId: event.botId, runId: event.runId, createdAt: event.createdAt, }; const without: ThreadMessage[] = []; const kept: ThreadMessage[] = []; for (const message of prev.messages) { if (message.id === next.id) continue; if (message.id.startsWith("progress:")) { if (message.runId) kept.push(message); } else { without.push(message); } } return { ...prev, cursor: event.seq, messages: [...without, next, ...kept] }; } if (event.type === "thread.cloud_agent") { return { ...prev, cursor: event.seq, messages: updateCloudAgentMessages(prev.messages, event.payload ?? {}), }; } if (event.type === "thread.message.created" || event.type === "thread.message.updated") { const role = (event.payload.role as ThreadMessage["role"]) ?? "bot"; const blocks = (event.payload.blocks as ThreadMessage["blocks"]) ?? []; const next: ThreadMessage = { id: String(event.payload.messageId ?? event.id), threadId: event.threadId, seq: event.seq, role, blocks, botId: event.botId, runId: event.runId, replyToMessageId: typeof event.payload.replyToMessageId === "string" ? event.payload.replyToMessageId : undefined, replyQuote: typeof event.payload.replyQuote === "string" ? event.payload.replyQuote : undefined, createdAt: event.createdAt, }; const replacedSubagentIds = new Set( blocks.filter((block) => block.kind === "subagent").map((block) => block.agentId), ); const liveId = progressMessageId(event); const { remaining } = takeLiveMessage(prev.messages, liveId); const without = remaining.filter((message) => !replacedSubagent(message, replacedSubagentIds)); return { ...prev, cursor: event.seq, messages: upsertMessageById(without, next) }; } return prev; } function updateMemberStatus( members: ThreadSnapshot["members"], botId: string, status: string, ): ThreadSnapshot["members"] { const member = members?.find((candidate) => candidate.botId === botId); if (!member || member.status === status) return members; return members?.map((candidate) => candidate.botId === botId ? { ...candidate, status } : candidate, ); } export function userHoldsComputerControl( computer: Pick | null | undefined, botId: string | undefined, ): boolean { return Boolean(botId && computer?.controlHolder === "user" && computer.controlBotId === botId); } /** True when a live bot run is blocking Take control (API would return 409). */ export function computerTakeoverBlocked( computer: Pick | null | undefined, runStatus?: string | null, ): boolean { if (!computer?.busyBotName) return false; // waiting_takeover is the bot asking for control; terminal/idle clears the block even if // busyBotName is briefly stale while the executor still holds the lease in finally. if (!runStatus || runStatus === "waiting_takeover") return false; return isActive(runStatus as RunStatus); } export function computerPanelAutoBoot( state: ComputerStatus["state"] | undefined, screenUrl?: string | null, ): "boot" | "recover-screen" | "wait" { if (state === "booting" || state === "suspended") return "wait"; if (state === "running") return screenUrl ? "wait" : "recover-screen"; return "boot"; } /** Auto panel reconnect must use computer.boot — never computer.recover (that destroys the sandbox). */ export function computerPanelAutoUsesBoot( action: ReturnType, ): boolean { return action === "boot" || action === "recover-screen"; } export function computerPanelNeedsMaintenance( state: ComputerStatus["state"] | undefined, booting: boolean, ): boolean { return !booting && (state === "error" || state === "stopped"); } export function reduceComputerStatus( prev: ComputerStatus | null, event: ProductEvent, ): ComputerStatus | null { if (!prev) return prev; if (!isComputerStatusEvent(event)) return prev; if (event.type === "computer.takeover.requested") { const retainedControl = event.payload.retainedControl === true; const next = { ...prev, busyBotName: null, takeoverRequested: true, ...(retainedControl ? {} : { controlHolder: "none" as const, controlBotId: null }), }; return prev.busyBotName === next.busyBotName && prev.takeoverRequested === next.takeoverRequested && prev.controlHolder === next.controlHolder && prev.controlBotId === next.controlBotId ? prev : next; } if (event.type === "computer.takeover.granted") { const takeoverRequested = event.payload.takeoverRequested === true; return prev.controlHolder === "user" && prev.controlBotId === event.botId && prev.takeoverRequested === takeoverRequested && prev.busyBotName === null ? prev : { ...prev, controlHolder: "user", controlBotId: event.botId, takeoverRequested, busyBotName: null, }; } if (event.type === "computer.takeover.released") { const holder = event.payload.holder; if (holder !== "bot" && holder !== "none") return prev; return prev.controlHolder === holder && prev.controlBotId === null && !prev.takeoverRequested && prev.busyBotName === null ? prev : { ...prev, controlHolder: holder, controlBotId: null, takeoverRequested: false, busyBotName: null, }; } const status = event.payload.status; if (!isComputerState(status)) return prev; const screenAvailable = status === "running" || status === "booting" || prev.screenAvailable; if (status === prev.state && screenAvailable === prev.screenAvailable) return prev; return { ...prev, state: status, screenAvailable, }; } export function isComputerStatusEvent(event: ProductEvent): boolean { return ( event.type === "computer.status" || event.type === "computer.takeover.requested" || event.type === "computer.takeover.granted" || event.type === "computer.takeover.released" ); } function isComputerState(value: unknown): value is ComputerStatus["state"] { return computerStates.has(value); } function replacedSubagent(message: ThreadMessage, agentIds: ReadonlySet) { if (agentIds.size === 0) return false; return message.blocks.some((block) => block.kind === "subagent" && agentIds.has(block.agentId)); }