Files
openclaw/src/gateway/server-chat.ts
T
Peter Steinberger f6fff4f7fd refactor: canonicalize aliases and classify test suites (#122407)
* refactor: use canonical re-export names

* fix(test): classify suite support as test source

* fix(agents): retarget gateway stub session-entry import

* test(gateway): retarget session-utils mock keys after alias removal
2026-08-11 21:18:34 -07:00

1795 lines
63 KiB
TypeScript

// Gateway chat runtime projects agent events into chat/session subscriber
// streams, lifecycle persistence, heartbeat visibility, and live UI updates.
import { performance } from "node:perf_hooks";
import type {
ChatEvent,
ChatRunStartupPhase,
} from "../../packages/gateway-protocol/src/schema/logs-chat.js";
import { isAgentLifecycleYieldedWaiting } from "../agents/agent-lifecycle-parent-state.js";
import {
buildAgentRunTerminalOutcomeFromLifecycleEvent,
classifyAgentRunTerminalOutcome,
} from "../agents/agent-run-terminal-outcome.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isTimeoutError, resolveFailoverReasonFromError } from "../agents/failover-error.js";
import type { FailoverReason } from "../agents/failover/signal.js";
import { resolveToolSearchCodeDisplayTarget } from "../agents/tool-display-common.js";
import { readToolValidationErrorSummary } from "../agents/tool-error-summary.js";
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js";
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import { normalizeAgentPlanSteps } from "../channels/streaming.js";
import { getRuntimeConfig } from "../config/io.js";
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import {
type AgentEventPayload,
type AgentEventRuntimePayload,
getAgentEventLifecycleGeneration,
} from "../infra/agent-events.js";
import { getAgentRunContext, getAgentRunContextOwnerStatus } from "../infra/agent-run-registry.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { logError } from "../logger.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import {
isAcpSessionKey,
isSubagentSessionKey,
parseCronRunScopeSuffix,
} from "../sessions/session-key-utils.js";
import { resolveAssistantEventPhase } from "../shared/chat-message-content.js";
import { setSafeTimeout } from "../utils/timer-delay.js";
import {
projectLiveAssistantBufferedText,
resolveAssistantLiveChatInput,
resolveMergedAssistantText,
shouldSuppressAssistantEventForLiveChat,
} from "./live-chat-projector.js";
import type { GatewayBroadcastFn, GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js";
import { isChatAbortMarkerCurrent } from "./server-chat-state.js";
import type {
BufferedAgentEvent,
ChatRunEntry,
ChatRunState,
SessionEventSubscriberRegistry,
SessionMessageSubscriberRegistry,
ToolEventRecipientRegistry,
} from "./server-chat-state.js";
import { loadGatewaySessionLifecycleSnapshot } from "./server-chat.load-gateway-session-row.runtime.js";
import { persistGatewaySessionLifecycleEvent } from "./server-chat.persist-session-lifecycle.runtime.js";
import { hasSessionChangeReceivers } from "./session-change-receivers.js";
import {
deriveGatewaySessionLifecycleProjectionPatch,
isRestartRecoveryLifecycleEvent,
isStaleLifecycleEventForSession,
} from "./session-lifecycle-state.js";
import {
resolveSessionSubscriptionKey,
resolveSessionSubscriptionKeys,
} from "./session-subscription-keys.js";
import { loadGatewaySessionEntryReadOnly } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
export {
createChatAbortMarker,
createChatRunRegistry,
createChatRunState,
createSessionEventSubscriberRegistry,
createSessionMessageSubscriberRegistry,
createToolEventRecipientRegistry,
} from "./server-chat-state.js";
export type {
ChatAbortMarker,
ChatRunEntry,
ChatRunRegistry,
ChatRunRegistration,
ChatRunState,
SessionEventSubscriberRegistry,
SessionMessageSubscriberRegistry,
ToolEventRecipientRegistry,
} from "./server-chat-state.js";
const CHAT_STATE_BY_TERMINAL_CLASSIFICATION = {
success: "done",
timeout: "error",
cancellation: "aborted",
failure: "error",
} as const;
function readChatRunStartupPhase(value: unknown): ChatRunStartupPhase | undefined {
switch (value) {
case "preparing_workspace":
case "provisioning_environment":
case "preparing_context":
case "starting_model":
return value;
default:
return undefined;
}
}
function projectToolSearchCodeEventForChannelPayload<T extends { data?: unknown }>(payload: T): T {
const data = payload.data;
if (!data || typeof data !== "object") {
return payload;
}
const record = data as Record<string, unknown>;
if (record.name !== "tool_search_code") {
return payload;
}
const target = resolveToolSearchCodeDisplayTarget(record.args);
if (!target) {
return payload;
}
const projectedName = target.displayToolName ?? target.toolName;
if (!projectedName || projectedName === "tool_search_code") {
return payload;
}
// Channel/node subscribers render from event data, not the richer display
// helper used by Control UI. Project obvious bridge calls so verbose
// surfaces name the concrete tool while keeping the bridge identity available.
const projectedData: Record<string, unknown> = { ...record, name: projectedName };
if (target.displayArgs) {
projectedData.args = target.displayArgs;
} else if (target.detail) {
projectedData.args = { detail: target.detail };
}
if (target.bridgeVerb) {
projectedData.bridgeToolName = "tool_search_code";
projectedData.bridgeTargetToolName = target.toolName;
projectedData.bridgeVerb = target.bridgeVerb;
}
return { ...payload, data: projectedData };
}
function resolveHeartbeatAckMaxChars(): number {
return DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
}
function resolveHeartbeatContext(runId: string, sourceRunId?: string) {
const primary = getAgentRunContext(runId);
if (primary?.isHeartbeat) {
return primary;
}
if (sourceRunId && sourceRunId !== runId) {
const source = getAgentRunContext(sourceRunId);
if (source?.isHeartbeat) {
return source;
}
}
return primary;
}
/**
* Check if heartbeat ACK/noise should be hidden from interactive chat surfaces.
*/
function shouldHideHeartbeatChatOutput(runId: string, sourceRunId?: string): boolean {
const runContext = resolveHeartbeatContext(runId, sourceRunId);
if (!runContext?.isHeartbeat) {
return false;
}
try {
const cfg = getRuntimeConfig();
const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
return !visibility.showOk;
} catch {
// Default to suppressing if we can't load config
return true;
}
}
function shouldSuppressHeartbeatToolEvents(runId: string, sourceRunId?: string): boolean {
return Boolean(resolveHeartbeatContext(runId, sourceRunId)?.isHeartbeat);
}
function shouldMirrorAssistantEventToHiddenSessionMessages(data: unknown): boolean {
if (!data || typeof data !== "object") {
return false;
}
const record = data as { text?: unknown; delta?: unknown };
const hasText = typeof record.text === "string" && record.text.length > 0;
const hasDelta = typeof record.delta === "string" && record.delta.length > 0;
if (!hasText && !hasDelta) {
return false;
}
return resolveAssistantEventPhase(data) === "commentary";
}
function shouldMirrorAgentEventToHiddenSessionMessages(evt: AgentEventPayload): boolean {
return evt.stream === "thinking" || evt.stream === "approval" || evt.stream === "lifecycle";
}
function normalizeHeartbeatChatFinalText(params: {
runId: string;
sourceRunId?: string;
text: string;
}): { suppress: boolean; text: string } {
if (!shouldHideHeartbeatChatOutput(params.runId, params.sourceRunId)) {
return { suppress: false, text: params.text };
}
const stripped = stripHeartbeatToken(params.text, {
mode: "heartbeat",
maxAckChars: resolveHeartbeatAckMaxChars(),
});
if (!stripped.didStrip) {
return { suppress: false, text: params.text };
}
if (stripped.shouldSkip) {
return { suppress: true, text: "" };
}
return { suppress: false, text: stripped.text };
}
/**
* Keep this aligned with the agent.wait lifecycle-error grace so chat surfaces
* do not finalize a run before fallback or retry reuses the same runId.
*/
const AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000;
const LIVE_TEXT_PACING_MS = 75;
export type ChatEventBroadcast = GatewayBroadcastFn;
export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
// Derived from ChatErrorEventSchema.errorKind (gateway-protocol); keep set in sync.
type ChatErrorKind = NonNullable<Extract<ChatEvent, { state: "error" }>["errorKind"]>;
const CHAT_ERROR_KINDS = new Set<ChatErrorKind>([
"refusal",
"timeout",
"rate_limit",
"context_length",
"unknown",
]);
const CHAT_ERROR_KIND_BY_FAILOVER_REASON = {
auth: undefined,
auth_permanent: undefined,
format: undefined,
rate_limit: "rate_limit",
overloaded: "rate_limit",
billing: undefined,
server_error: undefined,
timeout: undefined,
tls_certificate: undefined,
context_overflow: "context_length",
model_not_found: undefined,
session_expired: undefined,
empty_response: undefined,
no_error_details: undefined,
unclassified: undefined,
unknown: undefined,
} satisfies Record<FailoverReason, ChatErrorKind | undefined>;
function readChatErrorKind(value: unknown): ChatErrorKind | undefined {
return typeof value === "string" && CHAT_ERROR_KINDS.has(value as ChatErrorKind)
? (value as ChatErrorKind)
: undefined;
}
// Refusal first (stop-reason fact, not FailoverReason); then canonical failover map.
export function resolveChatErrorKindFromError(error: unknown): ChatErrorKind | undefined {
if (error === undefined) {
return undefined;
}
const message = formatErrorMessage(error).toLowerCase();
if (
message.includes("refusal") ||
message.includes("content_filter") ||
message.includes("sensitive") ||
message.includes("unhandled stop reason: refusal_policy")
) {
return "refusal";
}
const reason = resolveFailoverReasonFromError(error);
if (reason) {
const errorKind = CHAT_ERROR_KIND_BY_FAILOVER_REASON[reason];
if (errorKind) {
return errorKind;
}
}
// FailoverReason "timeout" is the retryable-transient bucket and deliberately
// swallows generic 5xx; only genuinely timeout-shaped errors get the badge.
return isTimeoutError(error) ? "timeout" : undefined;
}
function excludeConnIds(
connIds: ReadonlySet<string>,
excludedConnIds: ReadonlySet<string> | undefined,
): ReadonlySet<string> {
if (!excludedConnIds || excludedConnIds.size === 0 || connIds.size === 0) {
return connIds;
}
const filtered = new Set<string>();
for (const connId of connIds) {
if (!excludedConnIds.has(connId)) {
filtered.add(connId);
}
}
return filtered;
}
type BroadcastDelta = { deltaText: string; replace?: true };
function resolveBroadcastDelta(params: {
text: string;
previousBroadcastText: string | undefined;
}): BroadcastDelta | undefined {
if (!params.text) {
return undefined;
}
const previous = params.previousBroadcastText;
if (previous === undefined) {
return { deltaText: params.text };
}
if (!params.text.startsWith(previous)) {
return { deltaText: params.text, replace: true };
}
const deltaText = params.text.slice(previous.length);
return deltaText ? { deltaText } : undefined;
}
export type AgentEventHandlerOptions = {
broadcast: ChatEventBroadcast;
broadcastToConnIds: GatewayBroadcastToConnIdsFn;
nodeSendToSession: NodeSendToSession;
agentRunSeq: Map<string, number>;
chatRunState: ChatRunState;
resolveSessionKeyForRun: (runId: string) => string | undefined;
clearAgentRunContext: (
runId: string,
lifecycleGeneration?: string,
contextClaimId?: string,
) => void;
toolEventRecipients: ToolEventRecipientRegistry;
sessionEventSubscribers: SessionEventSubscriberRegistry;
sessionMessageSubscribers: SessionMessageSubscriberRegistry;
loadGatewaySessionLifecycleSnapshotForEvent?: typeof loadGatewaySessionLifecycleSnapshot;
lifecycleErrorRetryGraceMs?: number;
isChatSendRunActive?: (runId: string) => boolean;
clearTrackedActiveRun?: (params: {
runId: string;
clientRunId: string;
sessionKey: string;
}) => void;
markTrackedRunTerminalPersisted?: (params: {
runId: string;
clientRunId: string;
sessionKey: string;
}) => void;
trackTrackedRunTerminalPersistence?: (params: {
runId: string;
clientRunId: string;
sessionKey: string;
sessionId?: string;
observedAt: number;
persistence: Promise<void>;
}) => void;
resolveActiveLifecycleGenerationForRun?: (runId: string) => string | undefined;
updateRunToolErrorSummary?: (params: {
runId: string;
clientRunId: string;
summary: string | undefined;
}) => void;
resolveSessionActiveRunState?: (params: {
requestedKey: string;
canonicalKey: string;
sessionId?: string;
agentId?: string;
}) => { active: boolean; runIds: string[] };
};
type AgentEventHandler = ((event: AgentEventPayload) => void) & {
dispose: () => void;
};
const AGENT_TEXT_THROTTLE_STREAMS = ["assistant", "thinking"] as const;
type AgentTextThrottleStream = (typeof AGENT_TEXT_THROTTLE_STREAMS)[number];
type LiveTextStream = "chat" | "agent";
type PendingLiveTextFlush = { timer: NodeJS.Timeout; flush: () => void };
type InternalChatRunRecord = ReturnType<ChatRunState["getOrCreate"]> & {
pendingTextFlushes?: Partial<Record<LiveTextStream, PendingLiveTextFlush>>;
};
function internalChatRunRecord(
record: ReturnType<ChatRunState["getOrCreate"]>,
): InternalChatRunRecord {
return record;
}
function cancelPendingLiveTextFlush(run: InternalChatRunRecord, stream: LiveTextStream): void {
const pending = run.pendingTextFlushes?.[stream];
if (!pending) {
return;
}
clearTimeout(pending.timer);
delete run.pendingTextFlushes?.[stream];
if (run.pendingTextFlushes && Object.keys(run.pendingTextFlushes).length === 0) {
delete run.pendingTextFlushes;
}
}
function scheduleLiveTextFlush(
run: InternalChatRunRecord,
stream: LiveTextStream,
delayMs: number,
flush: () => void,
): void {
const pendingFlushes = (run.pendingTextFlushes ??= {});
const existing = pendingFlushes[stream];
if (existing) {
existing.flush = flush;
return;
}
const timer = setSafeTimeout(() => {
const pending = run.pendingTextFlushes?.[stream];
if (!pending || pending.timer !== timer) {
return;
}
cancelPendingLiveTextFlush(run, stream);
pending.flush();
}, delayMs);
timer.unref?.();
pendingFlushes[stream] = { timer, flush };
}
function roundedChatSendTimingMs(value: number): number {
return Math.max(0, Math.round(value * 1000) / 1000);
}
export function createAgentEventHandler({
broadcast,
broadcastToConnIds,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun,
clearAgentRunContext,
toolEventRecipients,
sessionEventSubscribers,
sessionMessageSubscribers,
loadGatewaySessionLifecycleSnapshotForEvent = loadGatewaySessionLifecycleSnapshot,
lifecycleErrorRetryGraceMs = AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS,
isChatSendRunActive = () => false,
clearTrackedActiveRun,
markTrackedRunTerminalPersisted,
trackTrackedRunTerminalPersistence,
resolveActiveLifecycleGenerationForRun = () => undefined,
updateRunToolErrorSummary,
resolveSessionActiveRunState,
}: AgentEventHandlerOptions): AgentEventHandler {
const shouldProcessOwnedEvent = (evt: AgentEventRuntimePayload): boolean => {
const claimId = evt.contextClaimId;
if (!claimId) {
return true;
}
const lifecycleGeneration = evt.lifecycleGeneration;
if (!lifecycleGeneration || lifecycleGeneration !== getAgentEventLifecycleGeneration()) {
return false;
}
// A missing claim means detach or supersession revoked this terminal event.
return getAgentRunContextOwnerStatus(evt.runId, claimId, lifecycleGeneration) === "active";
};
const clearRunContextForEvent = (evt: AgentEventRuntimePayload): void => {
if (evt.contextClaimId) {
clearAgentRunContext(evt.runId, evt.lifecycleGeneration, evt.contextClaimId);
return;
}
clearAgentRunContext(evt.runId);
};
type TerminalLifecycleOptions = {
skipChatErrorFinal?: boolean;
suppressRestartRecoveryProjection?: boolean;
restartRecoveryState?: { suppress: boolean };
};
type PendingTerminalLifecycleError = {
timer: NodeJS.Timeout;
event: AgentEventRuntimePayload;
opts?: TerminalLifecycleOptions;
};
const pendingTerminalLifecycleErrors = new Map<string, PendingTerminalLifecycleError>();
const cancelPendingChatDeltaFlush = (clientRunId: string) => {
const record = chatRunState.runs.get(clientRunId);
if (record) {
cancelPendingLiveTextFlush(internalChatRunRecord(record), "chat");
}
};
const clearPendingTerminalLifecycleError = (runId: string, lifecycleGeneration?: string) => {
const pending = pendingTerminalLifecycleErrors.get(runId);
if (!pending) {
return;
}
if (
lifecycleGeneration &&
pending.event.lifecycleGeneration &&
lifecycleGeneration !== pending.event.lifecycleGeneration
) {
return;
}
clearTimeout(pending.timer);
pendingTerminalLifecycleErrors.delete(runId);
};
const resolveRestartRecoveryLifecycleState = (
sessionKey: string,
agentId: string | undefined,
event: AgentEventPayload,
): { suppress: boolean } => {
try {
const { entry } = loadGatewaySessionEntryReadOnly(sessionKey, {
...(agentId ? { agentId } : {}),
clone: false,
});
return { suppress: isRestartRecoveryLifecycleEvent({ entry, event }) };
} catch {
return { suppress: false };
}
};
// Native, ACP, and spawn-owned dashboard sessions can carry spawnedBy.
// Short-circuit everyone else so high-volume chat streams do not touch the
// session store. Results are cached per sessionKey because
// spawnedBy is immutable once set and resolveSpawnedBy sits on the hot event
// path (delta, flush, final, agent, seq-gap).
const spawnedByCache = new Map<string, string | null>();
const resolveSpawnedBy = (sessionKey: string): string | null => {
if (spawnedByCache.has(sessionKey)) {
return spawnedByCache.get(sessionKey)!;
}
// Non-lineage keys return null without polluting the cache; only eligible
// child-session results (positive or null) are worth memoising.
const isDashboardSession =
parseAgentSessionKey(sessionKey)?.rest.startsWith("dashboard:") === true;
if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey) && !isDashboardSession) {
return null;
}
let result: string | null = null;
try {
result = loadGatewaySessionLifecycleSnapshotForEvent(sessionKey).row?.spawnedBy ?? null;
} catch {
// result stays null
}
spawnedByCache.set(sessionKey, result);
return result;
};
const buildSessionEventSnapshot = (
sessionKey: string,
evt?: AgentEventPayload,
agentId?: string,
includeActiveRunState = false,
) => {
const snapshotOptions = agentId ? { agentId } : undefined;
const lifecycleSnapshot = loadGatewaySessionLifecycleSnapshotForEvent(
sessionKey,
snapshotOptions,
);
const { lifecycleRunId, row } = lifecycleSnapshot;
const omitUnscopedGlobalGoal = sessionKey === "global" && !agentId;
const lifecyclePatch =
evt &&
!isStaleLifecycleEventForSession({
owningSessionId: evt.sessionId,
currentSessionId: row?.sessionId,
eventRunId: evt.runId,
currentRunId: lifecycleRunId,
eventStartedAt: evt.data?.startedAt,
currentStartedAt: row?.startedAt,
})
? deriveGatewaySessionLifecycleProjectionPatch({
entry: row
? {
updatedAt: row.updatedAt ?? undefined,
status: row.status,
lastRunError: row.lastRunError,
startedAt: row.startedAt,
endedAt: row.endedAt,
runtimeMs: row.runtimeMs,
abortedLastRun: row.abortedLastRun,
}
: undefined,
event: evt,
})
: {};
const activeRunState = includeActiveRunState
? resolveSessionActiveRunState?.({
requestedKey: sessionKey,
canonicalKey: row?.key ?? sessionKey,
...(row?.sessionId ? { sessionId: row.sessionId } : {}),
...(agentId ? { agentId } : {}),
})
: undefined;
// Agent lifecycle broadcasts merge into cached session rows in the UI.
// Always replace run identity so a newer start cannot inherit a completed run.
const activeRunFields = activeRunState
? { hasActiveRun: activeRunState.active, activeRunIds: activeRunState.runIds }
: {};
const clearsLastRunError =
Object.hasOwn(lifecyclePatch, "lastRunError") && lifecyclePatch.lastRunError === undefined;
const session = row
? {
...row,
...lifecyclePatch,
...activeRunFields,
// JSON drops undefined values, so a start/success must send null to
// evict a prior failure reason from the subscribed client row.
...(clearsLastRunError ? { lastRunError: null } : {}),
}
: undefined;
if (session && omitUnscopedGlobalGoal) {
delete session.goal;
}
const snapshotSource = session ?? lifecyclePatch;
return {
...(session ? { session } : {}),
updatedAt: snapshotSource.updatedAt,
sessionId: row?.sessionId,
kind: row?.kind,
channel: row?.channel,
subject: row?.subject,
groupChannel: row?.groupChannel,
space: row?.space,
chatType: row?.chatType,
origin: row?.origin,
spawnedBy: row?.spawnedBy,
spawnedWorkspaceDir: row?.spawnedWorkspaceDir,
spawnedCwd: row?.spawnedCwd,
forkedFromParent: sessionEntryForkedFromParent(row ?? undefined) ? true : undefined,
spawnDepth: row?.spawnDepth,
subagentRole: row?.subagentRole,
subagentControlScope: row?.subagentControlScope,
label: row?.label,
displayName: row?.displayName,
deliveryContext: row?.deliveryContext,
parentSessionKey: row?.parentSessionKey,
childSessions: row?.childSessions,
thinkingLevel: row?.thinkingLevel,
fastMode: row?.fastMode,
toolOverrides: row?.toolOverrides,
verboseLevel: row?.verboseLevel,
traceLevel: row?.traceLevel,
reasoningLevel: row?.reasoningLevel,
elevatedLevel: row?.elevatedLevel,
sendPolicy: row?.sendPolicy,
systemSent: row?.systemSent,
inputTokens: row?.inputTokens,
outputTokens: row?.outputTokens,
lastChannel: row?.lastChannel,
lastTo: row?.lastTo,
lastAccountId: row?.lastAccountId,
lastThreadId: row?.lastThreadId,
totalTokens: row?.totalTokens,
totalTokensFresh: row?.totalTokensFresh,
...(omitUnscopedGlobalGoal ? {} : { goal: row?.goal ?? null }),
contextTokens: row?.contextTokens,
estimatedCostUsd: row?.estimatedCostUsd,
responseUsage: row?.responseUsage,
// Carry the row-built channel-aware effective mode so the chat snapshot
// matches the session-event/list projections.
effectiveResponseUsage: row?.effectiveResponseUsage,
modelProvider: row?.modelProvider,
model: row?.model,
...activeRunFields,
status: snapshotSource.status,
lastRunError: snapshotSource.lastRunError ?? null,
startedAt: snapshotSource.startedAt,
endedAt: snapshotSource.endedAt,
runtimeMs: snapshotSource.runtimeMs,
abortedLastRun: snapshotSource.abortedLastRun,
};
};
const resolveSessionDeliveryKeys = (sessionKey: string, agentId?: string) => {
const canonicalKey = resolveSessionSubscriptionKey(sessionKey, agentId ?? "");
if (canonicalKey === sessionKey) {
return [canonicalKey];
}
const defaultAgentId = resolveDefaultAgentId(getRuntimeConfig());
return resolveSessionSubscriptionKeys(sessionKey, agentId ?? defaultAgentId, defaultAgentId);
};
const sendNodeSessionPayloadForAgent = (
sessionKey: string,
event: string,
payload: unknown,
agentId?: string,
) => {
for (const deliverySessionKey of resolveSessionDeliveryKeys(sessionKey, agentId)) {
nodeSendToSession(deliverySessionKey, event, payload);
}
};
const emitFirstAssistantChatSendTiming = (chatLink: ChatRunEntry | undefined) => {
const timing = chatLink?.chatSendTiming;
if (!timing || timing.firstAssistantEventSent) {
return;
}
timing.firstAssistantEventSent = true;
const nowMs = performance.now();
broadcastToConnIds(
"chat.send_timing",
{
phase: "first-assistant-event",
runId: chatLink.clientRunId,
sessionKey: chatLink.sessionKey,
...(chatLink.agentId ? { agentId: chatLink.agentId } : {}),
ackToPhaseMs: roundedChatSendTimingMs(nowMs - timing.ackedAtMs),
receivedToPhaseMs: roundedChatSendTimingMs(nowMs - timing.receivedAtMs),
...(timing.dispatchStartedAtMs !== undefined
? {
dispatchStartedToPhaseMs: roundedChatSendTimingMs(nowMs - timing.dispatchStartedAtMs),
}
: {}),
},
new Set([timing.connId]),
{ dropIfSlow: true },
);
};
const finalizeLifecycleEvent = (
evt: AgentEventRuntimePayload,
opts?: TerminalLifecycleOptions,
) => {
if (!shouldProcessOwnedEvent(evt)) {
return;
}
const lifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (lifecyclePhase !== "end" && lifecyclePhase !== "error") {
return;
}
const currentRunContext = getAgentRunContext(evt.runId);
const activeLifecycleGeneration = resolveActiveLifecycleGenerationForRun(evt.runId);
const currentLifecycleGeneration =
activeLifecycleGeneration ?? currentRunContext?.lifecycleGeneration;
const chatLink = evt.contextClaimId ? undefined : chatRunState.registry.peek(evt.runId);
const sessionAgentId = chatLink?.agentId ?? evt.agentId;
const eventSessionKey =
evt.deliverySessionKey ??
(typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined);
const isControlUiVisible =
evt.controlUiVisible ?? currentRunContext?.isControlUiVisible ?? true;
const projectSessionLifecycle =
evt.projectSessionLifecycle ?? currentRunContext?.projectSessionLifecycle ?? true;
const sessionKey =
chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId);
const restartRecoverySessionKey = eventSessionKey ?? sessionKey;
const restartRecoveryAgentId = evt.agentId ?? sessionAgentId;
const clientRunId = chatLink?.clientRunId ?? evt.runId;
const eventRunId = chatLink?.clientRunId ?? evt.runId;
const isAborted =
isChatAbortMarkerCurrent(chatRunState.runs.get(clientRunId)?.abortMarker, chatLink) ||
isChatAbortMarkerCurrent(chatRunState.runs.get(evt.runId)?.abortMarker, chatLink);
const lifecycleAborted = evt.data?.aborted === true;
const deliverySessionKeys = sessionKey
? resolveSessionDeliveryKeys(sessionKey, sessionAgentId)
: [];
const restartRecoveryState =
opts?.restartRecoveryState ??
(restartRecoverySessionKey
? resolveRestartRecoveryLifecycleState(
restartRecoverySessionKey,
restartRecoveryAgentId,
evt,
)
: undefined);
const suppressRestartRecoveryProjection =
opts?.suppressRestartRecoveryProjection === true ||
Boolean(
evt.lifecycleGeneration &&
activeLifecycleGeneration &&
evt.lifecycleGeneration !== activeLifecycleGeneration,
) ||
restartRecoveryState?.suppress === true;
const isSupersededRestartRecoveryEvent =
suppressRestartRecoveryProjection &&
Boolean(
evt.lifecycleGeneration &&
currentLifecycleGeneration &&
evt.lifecycleGeneration !== currentLifecycleGeneration,
);
if (isSupersededRestartRecoveryEvent) {
return;
}
if (
!suppressRestartRecoveryProjection &&
sessionKey &&
(isControlUiVisible ||
deliverySessionKeys.some(
(deliverySessionKey) => sessionMessageSubscribers.get(deliverySessionKey).size > 0,
))
) {
if (!isAborted) {
const finished = chatLink ? chatRunState.registry.shift(evt.runId) : undefined;
if (chatLink && !finished) {
clearRunContextForEvent(evt);
return;
}
const terminalSessionKey = finished?.sessionKey ?? sessionKey;
const terminalRunId = finished?.clientRunId ?? eventRunId;
const terminalAgentId = finished?.agentId ?? sessionAgentId;
const terminalOutcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({
phase: lifecyclePhase,
data: evt.data,
endedAt: evt.data?.endedAt ?? evt.ts,
});
const yieldedWaiting = isAgentLifecycleYieldedWaiting({
phase: lifecyclePhase,
yielded: evt.data?.yielded,
livenessState: evt.data?.livenessState,
stopReason: terminalOutcome.stopReason,
aborted: lifecycleAborted,
status: evt.data?.status,
timeoutPhase: evt.data?.timeoutPhase,
error: evt.data?.error,
});
const terminalState =
CHAT_STATE_BY_TERMINAL_CLASSIFICATION[classifyAgentRunTerminalOutcome(terminalOutcome)];
if (!(opts?.skipChatErrorFinal && terminalState === "error")) {
emitChatTerminal(
terminalSessionKey,
terminalRunId,
evt.runId,
evt.seq,
terminalState,
terminalOutcome.error ?? evt.data?.error,
terminalOutcome.stopReason,
readChatErrorKind(evt.data?.errorKind) ??
resolveChatErrorKindFromError(evt.data?.error),
{
agentId: terminalAgentId,
controlUiVisible: isControlUiVisible,
firstAssistantTimingEntry: finished,
abortErrorMessage: readToolValidationErrorSummary(evt.data?.toolErrorSummary),
yielded: yieldedWaiting ? true : undefined,
},
);
}
} else if (chatLink) {
chatRunState.registry.remove(evt.runId, clientRunId, sessionKey);
}
}
toolEventRecipients.markFinal(evt.runId);
chatRunState.clearRun(clientRunId);
if (suppressRestartRecoveryProjection && chatLink) {
chatRunState.registry.remove(evt.runId, clientRunId, sessionKey);
}
clearRunContextForEvent(evt);
agentRunSeq.delete(evt.runId);
agentRunSeq.delete(clientRunId);
if (sessionKey) {
clearTrackedActiveRun?.({ runId: evt.runId, clientRunId, sessionKey });
if (!suppressRestartRecoveryProjection && projectSessionLifecycle) {
const persistence = persistGatewaySessionLifecycleEvent({
sessionKey,
agentId: sessionAgentId,
event: evt,
});
trackTrackedRunTerminalPersistence?.({
runId: evt.runId,
clientRunId,
sessionKey,
sessionId: evt.sessionId,
observedAt: evt.ts,
persistence,
});
const broadcastSessionChange = (snapshotEvent?: AgentEventPayload) => {
if (parseCronRunScopeSuffix(sessionKey).runId) {
return;
}
const sessionEventConnIds = sessionEventSubscribers.getAll();
if (!hasSessionChangeReceivers(sessionEventConnIds)) {
return;
}
broadcastToConnIds(
"sessions.changed",
{
sessionKey,
...(sessionAgentId ? { agentId: sessionAgentId } : {}),
phase: lifecyclePhase,
runId: evt.runId,
...(eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}),
ts: evt.ts,
...buildSessionEventSnapshot(sessionKey, snapshotEvent, sessionAgentId, true),
},
sessionEventConnIds,
{ dropIfSlow: true },
);
};
const markPersisted = () => {
markTrackedRunTerminalPersisted?.({
runId: evt.runId,
clientRunId,
sessionKey,
});
};
// Terminal writes serialize with restart markers. Reload only after the
// write so subscribers see the canonical post-race session state.
void persistence
.then(() => {
markPersisted();
broadcastSessionChange();
})
.catch((err: unknown) => {
logError(
`gateway: terminal session persistence failed session=${formatForLog(sessionKey)} run=${formatForLog(evt.runId)} error=${formatForLog(err)}`,
);
// Persistence recovery remains tracked by the controller entry, but
// subscribers still need a terminal projection instead of hanging.
broadcastSessionChange(evt);
});
}
}
};
const scheduleTerminalLifecycleError = (
evt: AgentEventRuntimePayload,
opts?: TerminalLifecycleOptions,
) => {
clearPendingTerminalLifecycleError(evt.runId);
const timer = setSafeTimeout(() => {
const pending = pendingTerminalLifecycleErrors.get(evt.runId);
if (!pending || pending.timer !== timer) {
return;
}
pendingTerminalLifecycleErrors.delete(evt.runId);
finalizeLifecycleEvent(pending.event, pending.opts);
}, lifecycleErrorRetryGraceMs);
timer.unref?.();
pendingTerminalLifecycleErrors.set(evt.runId, { timer, event: evt, opts });
};
const broadcastChatDelta = (
sessionKey: string,
agentId: string | undefined,
clientRunId: string,
sourceRunId: string,
seq: number,
text: string,
opts?: { controlUiVisible?: boolean; firstAssistantTimingEntry?: ChatRunEntry },
) => {
cancelPendingChatDeltaFlush(clientRunId);
const run = chatRunState.getOrCreate(clientRunId);
const broadcastDelta = resolveBroadcastDelta({
text,
previousBroadcastText: run.deltaLastBroadcastText,
});
if (!broadcastDelta) {
return;
}
const now = Date.now();
run.deltaSentAt = now;
run.deltaLastBroadcastText = text;
const spawnedBy = resolveSpawnedBy(sessionKey);
const payload = {
runId: clientRunId,
sessionKey,
...(agentId ? { agentId } : {}),
...(spawnedBy && { spawnedBy }),
seq,
state: "delta" as const,
deltaText: broadcastDelta.deltaText,
...(broadcastDelta.replace ? { replace: true as const } : {}),
message: {
role: "assistant",
content: [{ type: "text", text }],
timestamp: now,
},
};
emitFirstAssistantChatSendTiming(
opts?.firstAssistantTimingEntry ?? chatRunState.registry.peek(sourceRunId),
);
sendChatPayload(sessionKey, payload, {
agentId,
controlUiVisible: opts?.controlUiVisible ?? true,
dropIfSlow: true,
});
};
const scheduleChatDeltaFlush = (
sessionKey: string,
agentId: string | undefined,
clientRunId: string,
sourceRunId: string,
seq: number,
delayMs: number,
controlUiVisible: boolean | undefined,
) => {
const run = internalChatRunRecord(chatRunState.getOrCreate(clientRunId));
const flush = () => {
const projected = chatRunState.resolveBuffer(clientRunId);
if (projected.suppress || shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
return;
}
broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, projected.text, {
controlUiVisible,
});
};
scheduleLiveTextFlush(run, "chat", delayMs, flush);
};
const emitChatDelta = (
sessionKey: string,
agentId: string | undefined,
clientRunId: string,
sourceRunId: string,
seq: number,
text: string,
delta?: unknown,
opts?: { controlUiVisible?: boolean },
) => {
const run = chatRunState.getOrCreate(clientRunId);
const previousRawText = run.rawBuffer ?? "";
const mergedRawText = resolveMergedAssistantText({
previousText: previousRawText,
nextText: text,
nextDelta: typeof delta === "string" ? delta : "",
});
if (!mergedRawText) {
return;
}
const now = Date.now();
run.rawBuffer = mergedRawText;
run.bufferUpdatedAt = now;
const waitedMs = now - (run.deltaSentAt ?? 0);
if (waitedMs < LIVE_TEXT_PACING_MS) {
scheduleChatDeltaFlush(
sessionKey,
agentId,
clientRunId,
sourceRunId,
seq,
LIVE_TEXT_PACING_MS - waitedMs,
opts?.controlUiVisible,
);
return;
}
const projected = chatRunState.resolveBuffer(clientRunId);
const mergedText = projected.text;
if (projected.suppress || shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
return;
}
broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, mergedText, opts);
};
const resolveBufferedChatTextState = (
clientRunId: string,
sourceRunId: string,
options?: { suppressLeadFragments?: boolean },
) => {
const bufferedText = chatRunState.resolveBuffer(clientRunId).text.trim();
const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({
runId: clientRunId,
sourceRunId,
text: bufferedText,
});
const projected = projectLiveAssistantBufferedText(normalizedHeartbeatText.text.trim(), {
suppressLeadFragments: options?.suppressLeadFragments,
});
return {
text: projected.text.trim(),
shouldSuppressSilent: normalizedHeartbeatText.suppress || projected.suppress,
};
};
const flushBufferedChatDeltaIfNeeded = (
sessionKey: string,
agentId: string | undefined,
clientRunId: string,
sourceRunId: string,
seq: number,
opts?: { controlUiVisible?: boolean; firstAssistantTimingEntry?: ChatRunEntry },
) => {
cancelPendingChatDeltaFlush(clientRunId);
const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, {
suppressLeadFragments: true,
});
const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput(
clientRunId,
sourceRunId,
);
if (!text || shouldSuppressSilent || shouldSuppressHeartbeatStreaming) {
return;
}
broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, text, opts);
};
const sendChatPayload = (
sessionKey: string,
payload: unknown,
opts?: { agentId?: string; controlUiVisible?: boolean; dropIfSlow?: boolean },
) => {
const deliverySessionKeys = resolveSessionDeliveryKeys(sessionKey, opts?.agentId);
if (opts?.controlUiVisible ?? true) {
broadcast("chat", payload, {
dropIfSlow: opts?.dropIfSlow,
sessionKeys: deliverySessionKeys,
});
sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId);
return;
}
const recipients = new Set(
deliverySessionKeys.flatMap((deliveryKey) => [...sessionMessageSubscribers.get(deliveryKey)]),
);
if (recipients.size > 0) {
broadcastToConnIds("chat", payload, recipients, {
dropIfSlow: opts?.dropIfSlow,
sessionKeys: deliverySessionKeys,
});
}
};
const emitChatTerminal = (
sessionKey: string,
clientRunId: string,
sourceRunId: string,
seq: number,
jobState: "done" | "error" | "aborted",
error?: unknown,
stopReason?: string,
errorKind?: ChatErrorKind,
opts?: {
agentId?: string;
controlUiVisible?: boolean;
firstAssistantTimingEntry?: ChatRunEntry;
abortErrorMessage?: string;
yielded?: true;
},
) => {
const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, {
suppressLeadFragments: false,
});
// Flush any paced delta so streaming clients receive the complete text
// before the final event.
// Only flush if the buffered text differs from the last broadcast to avoid duplicates.
flushBufferedChatDeltaIfNeeded(sessionKey, opts?.agentId, clientRunId, sourceRunId, seq, opts);
chatRunState.clearRun(clientRunId);
const spawnedBy = resolveSpawnedBy(sessionKey);
if (jobState !== "error") {
const payload = {
runId: clientRunId,
sessionKey,
...(opts?.agentId ? { agentId: opts.agentId } : {}),
...(spawnedBy && { spawnedBy }),
seq,
state: jobState === "done" ? ("final" as const) : ("aborted" as const),
...(jobState === "aborted" && opts?.abortErrorMessage
? { errorMessage: opts.abortErrorMessage }
: {}),
...(stopReason && { stopReason }),
...(jobState === "done" && opts?.yielded ? { yielded: true as const } : {}),
message:
text && !shouldSuppressSilent
? {
role: "assistant",
content: [{ type: "text", text }],
timestamp: Date.now(),
}
: undefined,
};
sendChatPayload(sessionKey, payload, opts);
return;
}
const payload = {
runId: clientRunId,
sessionKey,
...(opts?.agentId ? { agentId: opts.agentId } : {}),
...(spawnedBy && { spawnedBy }),
seq,
state: "error" as const,
errorMessage: error ? formatForLog(error) : undefined,
...(errorKind && { errorKind }),
...(stopReason && { stopReason }),
};
sendChatPayload(sessionKey, payload, opts);
};
const sendAgentPayload = (
sessionKey: string | undefined,
payload: AgentEventPayload & { spawnedBy?: string },
opts?: { agentId?: string; controlUiVisible?: boolean; dropIfSlow?: boolean },
) => {
if (opts?.controlUiVisible ?? true) {
broadcast("agent", payload, {
sessionKeys: sessionKey ? resolveSessionDeliveryKeys(sessionKey, opts?.agentId) : undefined,
});
if (sessionKey) {
sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, opts?.agentId);
}
return;
}
if (!sessionKey) {
return;
}
const deliverySessionKeys = resolveSessionDeliveryKeys(sessionKey, opts?.agentId);
const recipients = new Set(
deliverySessionKeys.flatMap((deliveryKey) => [...sessionMessageSubscribers.get(deliveryKey)]),
);
if (recipients.size > 0) {
broadcastToConnIds("agent", payload, recipients, {
dropIfSlow: opts?.dropIfSlow,
sessionKeys: deliverySessionKeys,
});
}
};
const sendNodeAgentPayload = (
sessionKey: string | undefined,
payload: AgentEventPayload & { spawnedBy?: string },
agentId?: string,
) => {
if (sessionKey) {
sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, agentId);
}
};
const flushBufferedAgentDeltaIfNeeded = (clientRunId: string) => {
const run = chatRunState.runs.get(clientRunId);
if (run) {
cancelPendingLiveTextFlush(internalChatRunRecord(run), "agent");
}
const bufferedEntries = AGENT_TEXT_THROTTLE_STREAMS.flatMap((currentStream) => {
const state = run?.agentText?.[currentStream];
const buffered = state?.bufferedEvent;
if (!buffered) {
return [];
}
return [{ stream: currentStream, buffered }];
});
bufferedEntries.sort((a, b) => a.buffered.payload.seq - b.buffered.payload.seq);
for (const { stream: currentStream, buffered } of bufferedEntries) {
sendAgentPayload(buffered.sessionKey, buffered.payload, { agentId: buffered.agentId });
const state = run?.agentText?.[currentStream];
if (state) {
delete state.bufferedEvent;
state.lastSentAt = Date.now();
}
}
};
const resolveAgentTextThrottleStream = (
evt: AgentEventPayload,
): AgentTextThrottleStream | null =>
evt.stream === "assistant" ? "assistant" : evt.stream === "thinking" ? "thinking" : null;
const shouldCoalesceAgentTextEvent = (evt: AgentEventPayload) =>
resolveAgentTextThrottleStream(evt) !== null &&
typeof evt.data?.text === "string" &&
typeof evt.data.delta === "string" &&
evt.data.delta.length > 0 &&
!(Array.isArray(evt.data.mediaUrls) && evt.data.mediaUrls.length > 0) &&
typeof evt.data.mediaUrl !== "string" &&
evt.data.replace !== true &&
(evt.stream !== "assistant" || !shouldSuppressAssistantEventForLiveChat(evt.data));
const mergeBufferedAgentPayload = (
previous: BufferedAgentEvent,
next: BufferedAgentEvent,
): BufferedAgentEvent => {
if (previous.payload.stream !== next.payload.stream) {
return next;
}
const previousDelta = previous.payload.data.delta;
const nextDelta = next.payload.data.delta;
if (typeof previousDelta !== "string" || typeof nextDelta !== "string") {
return next;
}
return {
...next,
payload: {
...next.payload,
data: {
...next.payload.data,
delta: `${previousDelta}${nextDelta}`,
},
},
};
};
const sendOrBufferAgentTextEvent = (
clientRunId: string,
sessionKey: string | undefined,
agentId: string | undefined,
payload: AgentEventPayload & { spawnedBy?: string },
) => {
const stream = resolveAgentTextThrottleStream(payload);
if (!stream) {
sendAgentPayload(sessionKey, payload, { agentId });
return;
}
const now = Date.now();
const run = chatRunState.getOrCreate(clientRunId);
const agentText = (run.agentText ??= {});
const state = (agentText[stream] ??= {});
const last = state.lastSentAt;
if (last !== undefined && now - last < LIVE_TEXT_PACING_MS) {
const nextBuffered: BufferedAgentEvent = sessionKey
? { sessionKey, agentId, payload }
: { agentId, payload };
state.bufferedEvent = state.bufferedEvent
? mergeBufferedAgentPayload(state.bufferedEvent, nextBuffered)
: nextBuffered;
scheduleLiveTextFlush(
internalChatRunRecord(run),
"agent",
LIVE_TEXT_PACING_MS - (now - last),
() => flushBufferedAgentDeltaIfNeeded(clientRunId),
);
return;
}
flushBufferedAgentDeltaIfNeeded(clientRunId);
sendAgentPayload(sessionKey, payload, { agentId });
state.lastSentAt = now;
};
const resolveToolVerboseLevel = (runId: string, sessionKey?: string) => {
const runContext = getAgentRunContext(runId);
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
if (!sessionKey) {
return runVerbose ?? "off";
}
try {
const { cfg, entry } = loadGatewaySessionEntryReadOnly(sessionKey);
const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel);
const sessionUpdatedAt = typeof entry?.updatedAt === "number" ? entry.updatedAt : undefined;
const sessionChangedAfterRunStarted =
sessionUpdatedAt !== undefined &&
runContext?.registeredAt !== undefined &&
sessionUpdatedAt >= runContext.registeredAt;
if (sessionVerbose && (!runVerbose || sessionChangedAfterRunStarted)) {
return sessionVerbose;
}
if (runVerbose) {
return runVerbose;
}
const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault);
return defaultVerbose ?? "off";
} catch {
return runVerbose ?? "off";
}
};
const handleEvent = (event: AgentEventPayload) => {
const evt = event as AgentEventRuntimePayload;
if (!shouldProcessOwnedEvent(evt)) {
return;
}
const lifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
const chatLink = evt.contextClaimId ? undefined : chatRunState.registry.peek(evt.runId);
const sessionAgentId = chatLink?.agentId ?? evt.agentId;
const eventSessionKey =
evt.deliverySessionKey ??
(typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined);
const runContext = getAgentRunContext(evt.runId);
const activeLifecycleGeneration = resolveActiveLifecycleGenerationForRun(evt.runId);
const isControlUiVisible = evt.controlUiVisible ?? runContext?.isControlUiVisible ?? true;
const projectSessionLifecycle =
evt.projectSessionLifecycle ?? runContext?.projectSessionLifecycle ?? true;
const isHeartbeat = runContext?.isHeartbeat;
const sessionKey =
chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId);
const restartRecoverySessionKey = eventSessionKey ?? sessionKey;
const restartRecoveryAgentId = evt.agentId ?? sessionAgentId;
const clientRunId = chatLink?.clientRunId ?? evt.runId;
const eventRunId = chatLink?.clientRunId ?? evt.runId;
const eventForClients = chatLink ? { ...evt, runId: eventRunId } : evt;
const isAborted =
isChatAbortMarkerCurrent(chatRunState.runs.get(clientRunId)?.abortMarker, chatLink) ||
isChatAbortMarkerCurrent(chatRunState.runs.get(evt.runId)?.abortMarker, chatLink);
const restartRecoveryState = restartRecoverySessionKey
? resolveRestartRecoveryLifecycleState(restartRecoverySessionKey, restartRecoveryAgentId, evt)
: undefined;
const suppressRestartRecoveryLifecycle =
lifecyclePhase !== null &&
(Boolean(
evt.lifecycleGeneration &&
activeLifecycleGeneration &&
evt.lifecycleGeneration !== activeLifecycleGeneration,
) ||
restartRecoveryState?.suppress === true);
if (suppressRestartRecoveryLifecycle) {
clearPendingTerminalLifecycleError(evt.runId, evt.lifecycleGeneration);
if (lifecyclePhase === "end" || lifecyclePhase === "error") {
finalizeLifecycleEvent(evt, {
suppressRestartRecoveryProjection: true,
restartRecoveryState,
});
}
return;
}
if (lifecyclePhase !== null && lifecyclePhase !== "error") {
clearPendingTerminalLifecycleError(evt.runId);
}
// Include sessionKey so Control UI can filter tool streams per session.
const spawnedBy = sessionKey ? resolveSpawnedBy(sessionKey) : null;
const agentPayload = sessionKey
? {
...eventForClients,
sessionKey,
...(sessionAgentId ? { agentId: sessionAgentId } : {}),
...(spawnedBy && { spawnedBy }),
...(isHeartbeat !== undefined && { isHeartbeat }),
}
: {
...eventForClients,
...(isHeartbeat !== undefined && { isHeartbeat }),
};
const hasSessionMessageSubscribers = sessionKey
? resolveSessionDeliveryKeys(sessionKey, sessionAgentId).some(
(deliverySessionKey) => sessionMessageSubscribers.get(deliverySessionKey).size > 0,
)
: false;
const last = agentRunSeq.get(evt.runId) ?? 0;
const isToolEvent = evt.stream === "tool";
const isItemEvent = evt.stream === "item";
const toolVerbose = isToolEvent ? resolveToolVerboseLevel(evt.runId, sessionKey) : "off";
const suppressHeartbeatToolEvents =
isToolEvent && shouldSuppressHeartbeatToolEvents(clientRunId, evt.runId);
const shouldCoalesceAgentEvent = shouldCoalesceAgentTextEvent(evt);
// Channel/node subscribers respect verbose; authenticated Control UI
// recipients need tool result payloads to render live tool cards.
const channelToolPayload =
isToolEvent && toolVerbose !== "full"
? (() => {
const data = evt.data ? { ...evt.data } : {};
delete data.result;
delete data.partialResult;
return { ...agentPayload, data };
})()
: agentPayload;
if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) {
flushBufferedAgentDeltaIfNeeded(clientRunId);
broadcast(
"agent",
{
runId: eventRunId,
stream: "error",
ts: Date.now(),
sessionKey,
...(spawnedBy && { spawnedBy }),
...(isHeartbeat !== undefined && { isHeartbeat }),
data: {
reason: "seq gap",
expected: last + 1,
received: evt.seq,
},
},
{
sessionKeys: sessionKey
? resolveSessionDeliveryKeys(sessionKey, sessionAgentId)
: undefined,
},
);
}
agentRunSeq.set(evt.runId, evt.seq);
if (evt.stream === "assistant") {
updateRunToolErrorSummary?.({ runId: evt.runId, clientRunId, summary: undefined });
}
if (evt.stream === "plan" && evt.data?.phase === "update") {
const steps = normalizeAgentPlanSteps(evt.data.steps) ?? [];
const explanation =
typeof evt.data.explanation === "string" ? evt.data.explanation.trim() : "";
chatRunState.getOrCreate(clientRunId).planSnapshot = {
steps,
...(explanation ? { explanation } : {}),
};
}
if (
chatLink &&
isControlUiVisible &&
!isAborted &&
((isToolEvent && !suppressHeartbeatToolEvents) || isItemEvent)
) {
// Persist the client-facing identity after run/session remapping. Route
// changes discard transient UI rows, so history replay must use the same
// payload identity as live delivery or tool results cannot reconcile.
chatRunState.recordProgressEvent(clientRunId, agentPayload);
}
if (evt.stream === "run_status") {
const phase = readChatRunStartupPhase(evt.data?.phase);
if (phase && chatLink && isControlUiVisible && sessionKey && !isAborted) {
const payload = {
runId: clientRunId,
sessionKey,
...(sessionAgentId ? { agentId: sessionAgentId } : {}),
...(spawnedBy && { spawnedBy }),
seq: evt.seq,
state: "status" as const,
phase,
} satisfies ChatEvent;
sendChatPayload(sessionKey, payload, {
agentId: sessionAgentId,
controlUiVisible: true,
dropIfSlow: true,
});
}
}
if (isToolEvent) {
const toolPhase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
if (toolPhase === "start") {
updateRunToolErrorSummary?.({ runId: evt.runId, clientRunId, summary: undefined });
} else if (toolPhase === "result") {
updateRunToolErrorSummary?.({
runId: evt.runId,
clientRunId,
summary: readToolValidationErrorSummary(evt.data?.toolErrorSummary),
});
}
// Flush pending assistant text before tool-start events so clients can
// render complete pre-tool text above tool cards (not truncated by delta throttle).
if (
toolPhase === "start" &&
(isControlUiVisible || hasSessionMessageSubscribers) &&
sessionKey &&
!isAborted &&
!suppressHeartbeatToolEvents
) {
flushBufferedChatDeltaIfNeeded(
sessionKey,
sessionAgentId,
clientRunId,
evt.runId,
evt.seq,
{
controlUiVisible: isControlUiVisible,
},
);
flushBufferedAgentDeltaIfNeeded(clientRunId);
}
// Always broadcast tool events to registered WS recipients with
// tool-events capability, regardless of verboseLevel. The verbose
// setting only controls whether tool details are sent as channel
// messages to messaging surfaces (Telegram, Discord, etc.). Carry the
// delivery key so scoped clients must also own the session subscription.
const runToolRecipients = toolEventRecipients.get(evt.runId);
if (
isControlUiVisible &&
!suppressHeartbeatToolEvents &&
runToolRecipients &&
runToolRecipients.size > 0
) {
broadcastToConnIds(
"agent",
sessionKey
? {
...agentPayload,
...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId),
}
: agentPayload,
runToolRecipients,
{
sessionKeys: sessionKey
? resolveSessionDeliveryKeys(sessionKey, sessionAgentId)
: undefined,
},
);
}
if (!isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) {
sendAgentPayload(
sessionKey,
{ ...agentPayload, ...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId) },
{ agentId: sessionAgentId, controlUiVisible: false, dropIfSlow: true },
);
}
// Session subscribers power operator UIs that attach to an existing
// in-flight session after the run has already started. Those clients do
// not know the runId in advance, so they cannot register as run-scoped
// tool recipients. Mirror tool lifecycle onto a session-scoped event so
// they can render live pending tool cards without polling history.
if (isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) {
const sessionSubscribers = excludeConnIds(
sessionEventSubscribers.getAll(),
runToolRecipients,
);
if (sessionSubscribers.size > 0) {
broadcastToConnIds(
"session.tool",
{
...agentPayload,
...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId),
},
sessionSubscribers,
{ dropIfSlow: true },
);
}
}
} else {
const itemPhase = isItemEvent && typeof evt.data?.phase === "string" ? evt.data.phase : "";
if (
itemPhase === "start" &&
(isControlUiVisible || hasSessionMessageSubscribers) &&
!isAborted
) {
if (sessionKey) {
flushBufferedChatDeltaIfNeeded(
sessionKey,
sessionAgentId,
clientRunId,
evt.runId,
evt.seq,
{
controlUiVisible: isControlUiVisible,
},
);
}
flushBufferedAgentDeltaIfNeeded(clientRunId);
}
if (isControlUiVisible) {
if (shouldCoalesceAgentEvent) {
sendOrBufferAgentTextEvent(clientRunId, sessionKey, sessionAgentId, agentPayload);
} else {
flushBufferedAgentDeltaIfNeeded(clientRunId);
sendAgentPayload(sessionKey, agentPayload, {
agentId: sessionAgentId,
controlUiVisible: isControlUiVisible,
});
const textThrottleStream = resolveAgentTextThrottleStream(evt);
if (
textThrottleStream &&
(typeof evt.data.delta === "string" || evt.data.replace === true)
) {
const agentText = (chatRunState.getOrCreate(clientRunId).agentText ??= {});
(agentText[textThrottleStream] ??= {}).lastSentAt = Date.now();
}
}
} else if (
sessionKey &&
hasSessionMessageSubscribers &&
(shouldMirrorAgentEventToHiddenSessionMessages(evt) ||
(!isAborted &&
evt.stream === "assistant" &&
shouldMirrorAssistantEventToHiddenSessionMessages(evt.data)))
) {
sendAgentPayload(
sessionKey,
{ ...agentPayload, ...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId) },
{ agentId: sessionAgentId, controlUiVisible: false, dropIfSlow: true },
);
}
if (!isControlUiVisible && isItemEvent && sessionKey && hasSessionMessageSubscribers) {
sendAgentPayload(
sessionKey,
{ ...agentPayload, ...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId) },
{ agentId: sessionAgentId, controlUiVisible: false, dropIfSlow: true },
);
}
}
if ((isControlUiVisible || hasSessionMessageSubscribers) && sessionKey) {
// Send tool events to node/channel subscribers only when verbose is enabled;
// WS clients already received the event above via broadcastToConnIds.
if (
isControlUiVisible &&
isToolEvent &&
!suppressHeartbeatToolEvents &&
toolVerbose !== "off"
) {
sendNodeAgentPayload(
sessionKey,
projectToolSearchCodeEventForChannelPayload({
...channelToolPayload,
...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId),
}),
sessionAgentId,
);
}
const assistantLiveChatInput =
evt.stream === "assistant" ? resolveAssistantLiveChatInput(evt.data) : undefined;
if (
!isAborted &&
assistantLiveChatInput &&
!shouldSuppressAssistantEventForLiveChat(evt.data)
) {
emitChatDelta(
sessionKey,
sessionAgentId,
clientRunId,
evt.runId,
evt.seq,
assistantLiveChatInput.text,
assistantLiveChatInput.delta,
{
controlUiVisible: isControlUiVisible,
},
);
}
}
if (lifecyclePhase === "error") {
const skipChatErrorFinal = isChatSendRunActive(evt.runId) && !chatLink;
const isFallbackExhaustedFailure = evt.data?.fallbackExhaustedFailure === true;
// Per-attempt provider errors keep the retry grace so fallback can reuse
// the runId. Once the runner marks fallback as exhausted, clear chat state
// immediately so webchat sessions do not stay in progress until the timer.
if (isAborted || isFallbackExhaustedFailure || lifecycleErrorRetryGraceMs <= 0) {
// finalizeLifecycleEvent clears the buffer itself, after emitChatTerminal
// has flushed the throttled tail and resolved the terminal message.
finalizeLifecycleEvent(evt, { skipChatErrorFinal, restartRecoveryState });
} else {
// Deliver the throttled tail before isolating the buffer so a fallback
// attempt cannot merge onto the failed attempt's text.
if (sessionKey) {
flushBufferedChatDeltaIfNeeded(
sessionKey,
sessionAgentId,
clientRunId,
evt.runId,
evt.seq,
{
controlUiVisible: isControlUiVisible,
},
);
}
chatRunState.clearRun(clientRunId);
scheduleTerminalLifecycleError(evt, { skipChatErrorFinal, restartRecoveryState });
}
return;
}
if (lifecyclePhase === "end") {
finalizeLifecycleEvent(evt, { restartRecoveryState });
return;
}
if (projectSessionLifecycle && sessionKey && lifecyclePhase === "start") {
void persistGatewaySessionLifecycleEvent({
sessionKey,
agentId: sessionAgentId,
event: evt,
}).catch((err: unknown) => {
// Surface the swallowed start-phase persistence failure: a silent write
// failure drops the run's start marker from restart-recovery accounting
// with no operator trace, matching the terminal-phase log below.
logError(
`gateway: start session persistence failed session=${formatForLog(sessionKey)} run=${formatForLog(evt.runId)} error=${formatForLog(err)}`,
);
});
const sessionEventConnIds = sessionEventSubscribers.getAll();
if (hasSessionChangeReceivers(sessionEventConnIds)) {
broadcastToConnIds(
"sessions.changed",
{
sessionKey,
...(sessionAgentId ? { agentId: sessionAgentId } : {}),
phase: lifecyclePhase,
runId: evt.runId,
...(eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}),
ts: evt.ts,
...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId, true),
},
sessionEventConnIds,
{ dropIfSlow: true },
);
}
}
};
return Object.assign(handleEvent, {
dispose: () => {
// Deferred provider errors belong to this gateway subscription. Letting
// them outlive shutdown can project stale terminal state into a successor.
for (const pending of pendingTerminalLifecycleErrors.values()) {
clearTimeout(pending.timer);
}
pendingTerminalLifecycleErrors.clear();
},
});
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */