refactor: split agent and chat orchestration ownership (#122318)

* refactor(agents): split subscription controllers

* refactor(gateway): split chat send dispatch

* style(gateway): format chat send handler
This commit is contained in:
Peter Steinberger
2026-08-11 17:33:20 -07:00
committed by GitHub
parent 5b843e1c3f
commit 22b3c2530f
7 changed files with 1637 additions and 1413 deletions
-1
View File
@@ -382,7 +382,6 @@ src/agents/embedded-agent-runner/transcript-file-state.test.ts
src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts
src/agents/embedded-agent-subscribe.handlers.tools.test.ts
src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts
src/agents/embedded-agent-subscribe.ts
src/agents/failover-error.test.ts
src/agents/failover-error.ts
src/agents/harness/native-hook-relay.test.ts
@@ -0,0 +1,273 @@
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { normalizeTextForComparison } from "./embedded-agent-helpers.js";
import type { BlockReplyPayload } from "./embedded-agent-payloads.js";
import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js";
import {
consumePendingAssistantReplyDirectivesIntoReply,
consumePendingToolMediaIntoReply,
hasAssistantVisibleReply,
readPendingToolMediaReply,
} from "./embedded-agent-subscribe.handlers.messages.replies.js";
import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js";
import type { SubscribeEmbeddedAgentSessionParams } from "./embedded-agent-subscribe.types.js";
type ReplyDeliveryParams = {
params: SubscribeEmbeddedAgentSessionParams;
state: EmbeddedAgentSubscribeContext["state"];
log: EmbeddedAgentSubscribeContext["log"];
};
export function createReplyDelivery({ params, state, log }: ReplyDeliveryParams) {
const assistantTexts = state.assistantTexts;
const pendingBlockReplyTasks = new Set<Promise<void>>();
const pendingPartialReplyTasks = new Set<Promise<void>>();
const shouldAllowSilentTurnText = (text: string | undefined) =>
Boolean(text && isSilentReplyText(text, SILENT_REPLY_TOKEN));
const emitAssistantStreamDataSafely = (
delivery: EmbeddedAgentSubscribeContext["state"]["deferredAssistantEvents"][number],
) => {
const { data } = delivery;
emitAgentEvent({
runId: params.runId,
stream: "assistant",
data,
});
if (params.onAgentEvent) {
runBestEffortCallback({
label: "assistant agent event",
log,
callback: () =>
params.onAgentEvent?.({
stream: "assistant",
data,
}),
});
}
if (delivery.emitPartialReply && params.onPartialReply && state.shouldEmitPartialReplies) {
try {
const maybeTask = params.onPartialReply(data);
if (isPromiseLike(maybeTask)) {
const task = Promise.resolve(maybeTask)
.then(() => undefined)
.catch((error: unknown) => {
log.warn(`assistant partial reply callback failed: ${String(error)}`);
});
pendingPartialReplyTasks.add(task);
void task.finally(() => {
pendingPartialReplyTasks.delete(task);
});
}
} catch (error) {
log.warn(`assistant partial reply callback failed: ${String(error)}`);
}
}
};
const emitAssistantStreamData = (
data: EmbeddedAgentSubscribeContext["state"]["deferredAssistantEvents"][number]["data"],
options?: { emitPartialReply?: boolean },
) => {
const delivery = { data, emitPartialReply: options?.emitPartialReply === true };
if (state.deferBlockReplyDelivery) {
state.deferredAssistantEvents.push(delivery);
return;
}
emitAssistantStreamDataSafely(delivery);
};
const flushDeferredAssistantEvents = () => {
if (state.deferredAssistantEvents.length === 0) {
return;
}
const deferred = state.deferredAssistantEvents.splice(0);
for (const delivery of deferred) {
emitAssistantStreamDataSafely(delivery);
}
};
const clearDeferredAssistantEvents = () => {
state.deferredAssistantEvents.length = 0;
};
const deferredToolMediaReplies = new WeakSet<BlockReplyPayload>();
const emitBlockReplySafely = (
payload: Parameters<NonNullable<SubscribeEmbeddedAgentSessionParams["onBlockReply"]>>[0],
options?: { assistantMessageIndex?: number },
): boolean => {
if (!params.onBlockReply) {
return false;
}
try {
const taggedPayload =
options?.assistantMessageIndex !== undefined
? setReplyPayloadMetadata(payload, {
assistantMessageIndex: options.assistantMessageIndex,
})
: payload;
const assistantMessageIndex =
options?.assistantMessageIndex ??
getReplyPayloadMetadata(taggedPayload)?.assistantMessageIndex;
const context = assistantMessageIndex === undefined ? undefined : { assistantMessageIndex };
const maybeTask = context
? params.onBlockReply(taggedPayload, context)
: params.onBlockReply(taggedPayload);
if (!isPromiseLike<void>(maybeTask)) {
return true;
}
const task = Promise.resolve(maybeTask).catch((err: unknown) => {
log.warn(`block reply callback failed: ${String(err)}`);
});
pendingBlockReplyTasks.add(task);
void task.finally(() => {
pendingBlockReplyTasks.delete(task);
});
return true;
} catch (err) {
log.warn(`block reply callback failed: ${String(err)}`);
return false;
}
};
const emitBlockReply = (
payload: BlockReplyPayload,
options?: { assistantMessageIndex?: number; consumePendingToolMedia?: boolean },
) => {
const withAssistantDirectives = consumePendingAssistantReplyDirectivesIntoReply(state, payload);
const consumesPendingToolMedia =
options?.consumePendingToolMedia !== false && readPendingToolMediaReply(state) !== null;
const withToolMedia =
options?.consumePendingToolMedia === false
? withAssistantDirectives
: consumePendingToolMediaIntoReply(state, withAssistantDirectives);
const assistantTranscriptMediaUrls = Array.from(new Set(payload.mediaUrls ?? []));
const taggedPayload =
options?.assistantMessageIndex !== undefined
? setReplyPayloadMetadata(withToolMedia, {
assistantMessageIndex: options.assistantMessageIndex,
...(assistantTranscriptMediaUrls.length > 0 ? { assistantTranscriptMediaUrls } : {}),
})
: withToolMedia;
if (state.deferBlockReplyDelivery) {
if (consumesPendingToolMedia) {
deferredToolMediaReplies.add(taggedPayload);
}
state.deferredBlockReplies.push(taggedPayload);
return;
}
const emitted = emitBlockReplySafely(taggedPayload, options);
if (emitted && !taggedPayload.isReasoning && hasAssistantVisibleReply(taggedPayload)) {
state.visibleBlockReplyCount += 1;
if (consumesPendingToolMedia) {
state.hasToolMediaBlockReply = true;
}
}
};
const flushDeferredBlockReplies = () => {
if (state.deferredBlockReplies.length === 0) {
return;
}
const deferred = state.deferredBlockReplies.splice(0);
for (const payload of deferred) {
const emitted = emitBlockReplySafely(payload);
if (emitted && !payload.isReasoning && hasAssistantVisibleReply(payload)) {
state.visibleBlockReplyCount += 1;
if (deferredToolMediaReplies.has(payload)) {
state.hasToolMediaBlockReply = true;
}
}
}
};
const clearDeferredBlockReplies = () => {
state.deferredBlockReplies.length = 0;
};
const rememberAssistantText = (text: string) => {
state.lastAssistantTextMessageIndex = state.assistantMessageIndex;
state.lastAssistantTextTrimmed = text.trimEnd();
const normalized = normalizeTextForComparison(text);
state.lastAssistantTextNormalized = normalized.length > 0 ? normalized : undefined;
};
const shouldSkipAssistantText = (text: string) => {
if (state.lastAssistantTextMessageIndex !== state.assistantMessageIndex) {
return false;
}
const trimmed = text.trimEnd();
if (trimmed && trimmed === state.lastAssistantTextTrimmed) {
return true;
}
const normalized = normalizeTextForComparison(text);
if (normalized.length > 0 && normalized === state.lastAssistantTextNormalized) {
return true;
}
return false;
};
const pushAssistantText = (text: string) => {
if (!text) {
return;
}
if (params.silentExpected && !shouldAllowSilentTurnText(text)) {
return;
}
if (shouldSkipAssistantText(text)) {
return;
}
assistantTexts.push(text);
rememberAssistantText(text);
};
const finalizeAssistantTexts = (args: {
text: string;
addedDuringMessage: boolean;
chunkerHasBuffered: boolean;
}) => {
const { text, addedDuringMessage, chunkerHasBuffered } = args;
// If we're not streaming block replies, ensure the final payload includes
// the final text even when interim streaming was enabled.
if (state.includeReasoning && text && !params.onBlockReply) {
if (assistantTexts.length > state.assistantTextBaseline) {
assistantTexts.splice(
state.assistantTextBaseline,
assistantTexts.length - state.assistantTextBaseline,
text,
);
rememberAssistantText(text);
} else {
pushAssistantText(text);
}
state.suppressBlockChunks = true;
} else if (!addedDuringMessage && !chunkerHasBuffered && text) {
// Non-streaming models (no text_delta): ensure assistantTexts gets the final
// text when the chunker has nothing buffered to drain.
pushAssistantText(text);
}
state.assistantTextBaseline = assistantTexts.length;
};
const waitForPendingEvents = async () => {
// Partial presentation stays concurrent with provider events, but terminal
// settlement must observe callbacks launched while the event chain drains.
while (state.pendingEventChain || pendingPartialReplyTasks.size > 0) {
await Promise.allSettled([
...(state.pendingEventChain ? [state.pendingEventChain] : []),
...pendingPartialReplyTasks,
]);
}
};
return {
assistantTexts,
clearDeferredAssistantEvents,
clearDeferredBlockReplies,
emitAssistantStreamData,
emitBlockReply,
finalizeAssistantTexts,
flushDeferredAssistantEvents,
flushDeferredBlockReplies,
pendingBlockReplyTasks,
pushAssistantText,
shouldSkipAssistantText,
waitForPendingEvents,
};
}
@@ -0,0 +1,152 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
import { createEmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js";
import type { EmbeddedAgentSubscribeState } from "./embedded-agent-subscribe.handlers.types.js";
import type { SubscribeEmbeddedAgentSessionParams } from "./embedded-agent-subscribe.types.js";
import { createThinkingTagStreamState } from "./embedded-agent-utils.js";
import { mediaUrlsFromGeneratedAttachments } from "./generated-attachments.js";
import { hasGeneratedMediaCompletionEvent } from "./internal-event-contract.js";
import type { AgentInternalEvent } from "./internal-events.js";
function collectPendingMediaFromInternalEvents(
events: SubscribeEmbeddedAgentSessionParams["internalEvents"],
): {
mediaUrls: string[];
attachments: NonNullable<AgentInternalEvent["attachments"]>;
trustByUrl: Map<string, boolean>;
} {
if (!events?.length) {
return { mediaUrls: [], attachments: [], trustByUrl: new Map() };
}
const pending: string[] = [];
const attachments: NonNullable<AgentInternalEvent["attachments"]> = [];
const indexByUrl = new Map<string, number>();
const trustedByUrl = new Map<string, boolean>();
for (const event of events) {
const generatedMediaEvent = hasGeneratedMediaCompletionEvent([event]);
const attachmentByUrl = new Map(
(event.attachments ?? []).flatMap((attachment) => {
const reference = normalizeOptionalString(
attachment.path ?? attachment.url ?? attachment.mediaUrl ?? attachment.filePath,
);
return reference ? [[reference, attachment] as const] : [];
}),
);
const mediaUrls = [
...(Array.isArray(event.mediaUrls) ? event.mediaUrls : []),
...mediaUrlsFromGeneratedAttachments(event.attachments),
];
for (const mediaUrl of mediaUrls) {
const normalized = normalizeOptionalString(mediaUrl) ?? "";
if (!normalized) {
continue;
}
const metadata = attachmentByUrl.get(normalized);
const existingIndex = indexByUrl.get(normalized);
if (existingIndex !== undefined) {
trustedByUrl.set(normalized, trustedByUrl.get(normalized) === true || generatedMediaEvent);
if (metadata && Object.keys(attachments[existingIndex] ?? {}).length === 0) {
attachments[existingIndex] = metadata;
}
continue;
}
indexByUrl.set(normalized, pending.length);
trustedByUrl.set(normalized, generatedMediaEvent);
pending.push(normalized);
attachments.push(metadata ?? {});
}
}
return { mediaUrls: pending, attachments, trustByUrl: trustedByUrl };
}
export function createEmbeddedAgentSubscribeState(
params: SubscribeEmbeddedAgentSessionParams,
): EmbeddedAgentSubscribeState {
const reasoningMode = params.reasoningMode ?? "off";
const canShowReasoning = params.thinkingLevel !== "off";
const initialPendingToolMedia = collectPendingMediaFromInternalEvents(params.internalEvents);
return {
assistantTexts: [],
toolMetas: [],
acceptedSessionSpawns: [],
toolMetaById: new Map(),
toolSummaryById: new Set(),
liveEditDiffStateById: new Map(),
itemActiveIds: new Set(),
itemStartedCount: 0,
itemCompletedCount: 0,
assistantTurnCount: 0,
lastToolError: undefined,
blockReplyBreak: params.blockReplyBreak ?? "text_end",
reasoningMode,
includeReasoning: reasoningMode === "on" && canShowReasoning,
shouldEmitPartialReplies: !(reasoningMode === "on" && !params.onBlockReply),
streamReasoning:
(params.streamReasoningInNonStreamModes === true
? reasoningMode !== "on"
: reasoningMode === "stream") &&
canShowReasoning &&
typeof params.onReasoningStream === "function",
deltaBuffer: "",
thinkingTagStream: createThinkingTagStreamState(),
blockBuffer: "",
// Track if a streamed chunk opened a <think> block (stateful across chunks).
blockState: { thinking: false, final: false, inlineCode: createInlineCodeState() },
partialBlockState: { thinking: false, final: false, inlineCode: createInlineCodeState() },
lastStreamedAssistant: undefined,
lastStreamedAssistantCleaned: undefined,
emittedAssistantUpdate: false,
lastStreamedReasoning: undefined,
lastBlockReplyText: undefined,
lastDeliveredBlockReplyText: undefined,
deferBlockReplyDelivery: typeof params.onBeforeTerminalDelivery === "function",
deferredBlockReplies: [],
deferredAssistantEvents: [],
toolExecutionSinceLastBlockReply: false,
reasoningStreamOpen: false,
assistantMessageIndex: 0,
lastAssistantStreamContentIndex: undefined,
lastAssistantStreamItemId: undefined,
lastAssistantTextMessageIndex: -1,
lastAssistantTextNormalized: undefined,
lastAssistantTextTrimmed: undefined,
assistantTextBaseline: 0,
suppressBlockChunks: false, // Avoid late chunk inserts after final text merge.
lastReasoningSent: undefined,
pendingAssistantUsage: undefined,
assistantUsageCommitted: false,
compactionInFlight: false,
lastCompactionTokensAfter: undefined,
pendingCompactionRetry: 0,
compactionRetryResolve: undefined,
compactionRetryReject: undefined,
compactionRetryPromise: null,
unsubscribed: false,
replayState: createEmbeddedRunReplayState(params.initialReplayState),
livenessState: "working",
hadDeterministicSideEffect: false,
pendingEventChain: null,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
currentSourceMessagingToolSentTextsNormalized: [],
currentSourceMessagingToolHeldPartial: undefined,
messagingToolSentTargets: [],
heartbeatToolResponse: undefined,
messagingToolSentMediaUrls: [],
messagingToolSourceReplyPayloads: [],
messageToolOnlySourceReplyDelivered: false,
pendingMessagingTexts: new Map(),
pendingMessagingTargets: new Map(),
successfulCronAdds: 0,
pendingMessagingMediaUrls: new Map(),
pendingToolMediaUrls: initialPendingToolMedia.mediaUrls,
pendingToolMediaAttachments: initialPendingToolMedia.attachments,
pendingToolMediaTrustByUrl: initialPendingToolMedia.trustByUrl,
pendingToolAudioAsVoice: false,
hasToolMediaBlockReply: false,
visibleBlockReplyCount: 0,
pendingAssistantReplyDirectives: undefined,
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
};
}
@@ -0,0 +1,640 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { InlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
import {
buildCodeSpanIndex,
createInlineCodeState,
} from "../../packages/markdown-core/src/code-spans.js";
import type { FenceScanState } from "../../packages/markdown-core/src/fences.js";
import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { findFinalTagMatches } from "../shared/text/final-tags.js";
import { hasOrphanReasoningCloseBoundary } from "../shared/text/reasoning-tags.js";
import {
isMessagingToolDuplicateNormalized,
normalizeTextForComparison,
} from "./embedded-agent-helpers.js";
import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js";
import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js";
import type { SubscribeEmbeddedAgentSessionParams } from "./embedded-agent-subscribe.types.js";
import {
createThinkingTagStreamState,
stripDowngradedToolCallText,
THINKING_TAG_SCAN_RE,
} from "./embedded-agent-utils.js";
const STREAM_STRIPPED_BLOCK_TAG_NAMES = [
"final",
"think",
"thinking",
"thought",
"antthinking",
"antml:think",
"antml:thinking",
"antml:thought",
"mm:think",
"mm:thinking",
"mm:thought",
] as const;
function isPotentialTrailingBlockTagFragment(fragment: string): boolean {
if (!fragment.startsWith("<") || fragment.includes(">")) {
return false;
}
const body = fragment.toLowerCase().slice(1).trimStart().replace(/^\//, "").trimStart();
if (!body) {
return true;
}
const namePart = body.split(/[\s/>]/, 1)[0] ?? "";
if (!namePart) {
return true;
}
return STREAM_STRIPPED_BLOCK_TAG_NAMES.some((name) => {
return name.startsWith(namePart) || namePart === name;
});
}
function splitTrailingBlockTagFragment(
text: string,
isInsideCodeSpan: (index: number) => boolean,
): { text: string; pendingTagFragment?: string } {
const fragmentStart = text.lastIndexOf("<");
if (fragmentStart === -1 || isInsideCodeSpan(fragmentStart)) {
return { text };
}
const fragment = text.slice(fragmentStart);
if (!isPotentialTrailingBlockTagFragment(fragment)) {
return { text };
}
return {
text: text.slice(0, fragmentStart),
pendingTagFragment: fragment,
};
}
function splitTrailingFenceFragment(
text: string,
startsAtLineStart: boolean,
): { text: string; pendingFenceFragment?: string } {
const lineStart = text.lastIndexOf("\n") + 1;
const line = text.slice(lineStart);
if ((!startsAtLineStart && lineStart === 0) || !/^(?: {0,3})(?:`+|~+)$/.test(line)) {
return { text };
}
return {
text: text.slice(0, lineStart),
pendingFenceFragment: line,
};
}
type StreamRenderingParams = {
params: SubscribeEmbeddedAgentSessionParams;
state: EmbeddedAgentSubscribeContext["state"];
log: EmbeddedAgentSubscribeContext["log"];
blockChunker: EmbeddedAgentSubscribeContext["blockChunker"];
emitBlockReply: EmbeddedAgentSubscribeContext["emitBlockReply"];
pendingBlockReplyTasks: Set<Promise<void>>;
pushAssistantText: (text: string) => void;
shouldSkipAssistantText: (text: string) => boolean;
};
export function createStreamRendering({
params,
state,
log,
blockChunker,
emitBlockReply,
pendingBlockReplyTasks,
pushAssistantText,
shouldSkipAssistantText,
}: StreamRenderingParams) {
const messagingToolSentTextsNormalized = state.messagingToolSentTextsNormalized;
const messagingToolSourceReplyPayloads = state.messagingToolSourceReplyPayloads;
const replyDirectiveAccumulator = createStreamingDirectiveAccumulator();
const partialReplyDirectiveAccumulator = createStreamingDirectiveAccumulator();
const stripBlockTags = (
text: string,
stateLocal: {
thinking: boolean;
final: boolean;
inlineCode?: InlineCodeState;
fence?: FenceScanState;
reasoningInlineCode?: InlineCodeState;
reasoningFence?: FenceScanState;
reasoningPendingFenceFragment?: string;
finalInlineCode?: InlineCodeState;
finalFence?: FenceScanState;
pendingFenceFragment?: string;
pendingTagFragment?: string;
},
options?: { final?: boolean; completeMarkdownChunk?: boolean },
): string => {
const input = `${stateLocal.pendingFenceFragment ?? ""}${stateLocal.pendingTagFragment ?? ""}${text}`;
stateLocal.pendingFenceFragment = undefined;
stateLocal.pendingTagFragment = undefined;
if (!input) {
return text;
}
const { text: fenceInput, pendingFenceFragment } = options?.final
? { text: input, pendingFenceFragment: undefined }
: options?.completeMarkdownChunk
? { text: input, pendingFenceFragment: undefined }
: splitTrailingFenceFragment(input, stateLocal.fence?.atLineStart ?? true);
stateLocal.pendingFenceFragment = pendingFenceFragment;
if (!fenceInput) {
return "";
}
const inlineStateStart = stateLocal.inlineCode ?? createInlineCodeState();
const fenceStateStart = stateLocal.fence;
const initialCodeSpans = buildCodeSpanIndex(fenceInput, inlineStateStart, fenceStateStart);
const { text: scanText, pendingTagFragment } = options?.final
? { text: fenceInput, pendingTagFragment: undefined }
: splitTrailingBlockTagFragment(fenceInput, initialCodeSpans.isInside);
stateLocal.pendingTagFragment = pendingTagFragment;
if (!scanText) {
return "";
}
const codeSpans = buildCodeSpanIndex(scanText, inlineStateStart, fenceStateStart);
let processed = "";
THINKING_TAG_SCAN_RE.lastIndex = 0;
let lastIndex = 0;
let lastCodeIndex = 0;
let inThinking = stateLocal.thinking;
// Hidden reasoning has its own code state: malformed hidden fences must not
// mark later visible text as code, but literal close tags there stay hidden.
let hiddenInlineState: InlineCodeState = stateLocal.reasoningInlineCode
? { ...stateLocal.reasoningInlineCode }
: createInlineCodeState();
let hiddenFenceState: FenceScanState | undefined = stateLocal.reasoningFence?.open
? {
atLineStart: stateLocal.reasoningFence.atLineStart,
open: { ...stateLocal.reasoningFence.open },
}
: stateLocal.reasoningFence
? { atLineStart: stateLocal.reasoningFence.atLineStart }
: undefined;
let hiddenPendingFenceFragment = stateLocal.reasoningPendingFenceFragment;
stateLocal.reasoningPendingFenceFragment = undefined;
const advanceHiddenCodeState = (segment: string) => {
const hiddenInput = `${hiddenPendingFenceFragment ?? ""}${segment}`;
hiddenPendingFenceFragment = undefined;
if (!hiddenInput) {
return;
}
const { text: hiddenFenceInput, pendingFenceFragment: pendingFenceFragmentLocal } =
options?.final
? { text: hiddenInput, pendingFenceFragment: undefined }
: options?.completeMarkdownChunk
? { text: hiddenInput, pendingFenceFragment: undefined }
: splitTrailingFenceFragment(hiddenInput, hiddenFenceState?.atLineStart ?? true);
hiddenPendingFenceFragment = pendingFenceFragmentLocal;
if (!hiddenFenceInput) {
return;
}
const next = buildCodeSpanIndex(hiddenFenceInput, hiddenInlineState, hiddenFenceState);
hiddenInlineState = next.inlineState;
hiddenFenceState = next.fenceState;
};
for (const match of scanText.matchAll(THINKING_TAG_SCAN_RE)) {
const idx = match.index ?? 0;
const isClose = match[1] === "/";
if (inThinking) {
advanceHiddenCodeState(scanText.slice(lastCodeIndex, idx));
}
const isInsideHiddenCode =
inThinking && (hiddenInlineState.open || Boolean(hiddenFenceState?.open));
lastCodeIndex = idx + match[0].length;
if ((!inThinking && codeSpans.isInside(idx)) || isInsideHiddenCode) {
if (inThinking) {
advanceHiddenCodeState(match[0]);
}
continue;
}
if (!inThinking) {
if (isClose) {
const afterIndex = idx + match[0].length;
const before = scanText.slice(lastIndex, idx);
const after = scanText.slice(afterIndex);
if (hasOrphanReasoningCloseBoundary({ before, after })) {
processed = "";
} else {
processed += before;
}
lastIndex = afterIndex;
continue;
}
processed += scanText.slice(lastIndex, idx);
hiddenInlineState = createInlineCodeState();
hiddenFenceState = undefined;
hiddenPendingFenceFragment = undefined;
}
inThinking = !isClose;
if (!inThinking) {
hiddenInlineState = createInlineCodeState();
hiddenFenceState = undefined;
hiddenPendingFenceFragment = undefined;
}
lastIndex = idx + match[0].length;
}
if (inThinking) {
advanceHiddenCodeState(scanText.slice(lastCodeIndex));
}
if (!inThinking) {
processed += scanText.slice(lastIndex);
}
stateLocal.thinking = inThinking;
stateLocal.reasoningInlineCode = inThinking ? hiddenInlineState : undefined;
stateLocal.reasoningFence = inThinking ? hiddenFenceState : undefined;
stateLocal.reasoningPendingFenceFragment = inThinking ? hiddenPendingFenceFragment : undefined;
// If enforcement is disabled, we still strip the tags themselves to prevent
// hallucinations (e.g. Minimax copying the style) from leaking, but we
// do not enforce buffering/extraction logic.
const finalCodeSpans = buildCodeSpanIndex(processed, inlineStateStart, fenceStateStart);
if (!params.enforceFinalTag) {
stateLocal.inlineCode = finalCodeSpans.inlineState;
stateLocal.fence = finalCodeSpans.fenceState;
return stripFinalTagsOutsideCodeSpans(processed, finalCodeSpans.isInside);
}
// If enforcement is enabled, only return text that appeared inside a <final> block.
let result = "";
let lastFinalIndex = 0;
let inFinal = stateLocal.final;
let everInFinal = stateLocal.final;
for (const match of findFinalTagMatches(processed)) {
const idx = match.index;
if (finalCodeSpans.isInside(idx)) {
continue;
}
const isClose = match.isClose;
const isSelfClosing = match.isSelfClosing;
if (isSelfClosing) {
if (inFinal) {
result += processed.slice(lastFinalIndex, idx);
inFinal = false;
} else {
inFinal = true;
everInFinal = true;
}
lastFinalIndex = idx + match.text.length;
} else if (!inFinal && !isClose) {
// Found <final> start tag.
inFinal = true;
everInFinal = true;
lastFinalIndex = idx + match.text.length;
} else if (inFinal && isClose) {
// Found </final> end tag.
result += processed.slice(lastFinalIndex, idx);
inFinal = false;
lastFinalIndex = idx + match.text.length;
}
}
if (inFinal) {
result += processed.slice(lastFinalIndex);
}
stateLocal.final = inFinal;
// Strict Mode: If enforcing final tags, we MUST NOT return content unless
// we have seen a <final> tag. Otherwise, we leak "thinking out loud" text
// (e.g. "**Locating Manulife**...") that the model emitted without <think> tags.
if (!everInFinal) {
stateLocal.inlineCode = createInlineCodeState();
stateLocal.fence = finalCodeSpans.fenceState;
stateLocal.finalInlineCode = undefined;
stateLocal.finalFence = undefined;
return "";
}
// Hardened Cleanup: Remove any remaining <final> tags that might have been
// missed (e.g. nested tags or hallucinations) to prevent leakage.
const finalResultInlineStateStart = stateLocal.finalInlineCode ?? createInlineCodeState();
const finalResultFenceStateStart = stateLocal.finalFence;
const resultCodeSpans = buildCodeSpanIndex(
result,
finalResultInlineStateStart,
finalResultFenceStateStart,
);
stateLocal.inlineCode = finalCodeSpans.inlineState;
stateLocal.fence = finalCodeSpans.fenceState;
stateLocal.finalInlineCode = inFinal ? resultCodeSpans.inlineState : undefined;
stateLocal.finalFence = inFinal ? resultCodeSpans.fenceState : undefined;
return stripFinalTagsOutsideCodeSpans(result, resultCodeSpans.isInside);
};
const stripFinalTagsOutsideCodeSpans = (text: string, isInside: (index: number) => boolean) => {
let output = "";
let lastIndex = 0;
for (const match of findFinalTagMatches(text)) {
const idx = match.index;
if (isInside(idx)) {
continue;
}
output += text.slice(lastIndex, idx);
lastIndex = idx + match.text.length;
}
output += text.slice(lastIndex);
return output;
};
const hasMessageToolOnlySourceDelivery = () =>
params.sourceReplyDeliveryMode === "message_tool_only" &&
(state.messageToolOnlySourceReplyDelivered ||
params.hasDeliveredMessageToolOnlySourceReply?.() === true ||
messagingToolSourceReplyPayloads.length > 0);
const emitBlockChunk = (
text: string,
options?: { assistantMessageIndex?: number; final?: boolean; completeMarkdownChunk?: boolean },
) => {
if (state.suppressBlockChunks || params.silentExpected) {
return;
}
// Strip <think> and <final> blocks across chunk boundaries to avoid leaking reasoning.
// Also strip downgraded tool call text ([Tool Call: ...], [Historical context: ...], etc.).
const blockReplyText = stripDowngradedToolCallText(
stripBlockTags(text, state.blockState, {
final: options?.final === true,
completeMarkdownChunk: options?.completeMarkdownChunk === true,
}),
).trimEnd();
if (!blockReplyText) {
return;
}
if (blockReplyText === state.lastBlockReplyText) {
return;
}
const markBlockReplyTextHandled = () => {
state.lastBlockReplyText = blockReplyText;
state.lastDeliveredBlockReplyText = blockReplyText;
state.toolExecutionSinceLastBlockReply = false;
};
if (hasMessageToolOnlySourceDelivery()) {
markBlockReplyTextHandled();
return;
}
let chunk = blockReplyText;
let slicedPrefixReplay = false;
const lastDeliveredBlockReplyText = state.lastDeliveredBlockReplyText;
const blockReplySuffix = lastDeliveredBlockReplyText
? blockReplyText.slice(lastDeliveredBlockReplyText.length)
: "";
const prefixReplayCandidate = Boolean(
state.blockReplyBreak === "text_end" &&
state.toolExecutionSinceLastBlockReply &&
lastDeliveredBlockReplyText &&
lastDeliveredBlockReplyText.trimEnd().endsWith(":") &&
blockReplyText.length > lastDeliveredBlockReplyText.length &&
blockReplyText.startsWith(lastDeliveredBlockReplyText),
);
if (prefixReplayCandidate && !/^\s/.test(blockReplySuffix)) {
chunk = blockReplySuffix;
slicedPrefixReplay = true;
}
if (!chunk) {
return;
}
// Only check committed (successful) messaging tool texts - checking pending texts
// is risky because if the tool fails after suppression, the user gets no response
const normalizedChunk = normalizeTextForComparison(chunk);
const normalizedReplaySuffix = prefixReplayCandidate
? normalizeTextForComparison(blockReplySuffix.trimStart())
: "";
const isMessagingDuplicate =
isMessagingToolDuplicateNormalized(normalizedChunk, messagingToolSentTextsNormalized) ||
(prefixReplayCandidate &&
isMessagingToolDuplicateNormalized(
normalizedReplaySuffix,
messagingToolSentTextsNormalized,
));
if (isMessagingDuplicate) {
log.debug(
`Skipping block reply - already sent via messaging tool: ${truncateUtf16Safe(chunk, 50)}...`,
);
if (prefixReplayCandidate) {
markBlockReplyTextHandled();
}
return;
}
if (shouldSkipAssistantText(chunk)) {
if (slicedPrefixReplay) {
markBlockReplyTextHandled();
}
return;
}
if (!params.onBlockReply) {
pushAssistantText(chunk);
markBlockReplyTextHandled();
return;
}
const splitResult = replyDirectiveAccumulator.consume(chunk);
if (!splitResult) {
if (slicedPrefixReplay) {
markBlockReplyTextHandled();
}
return;
}
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = splitResult;
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice) {
if (slicedPrefixReplay) {
markBlockReplyTextHandled();
}
return;
}
pushAssistantText(chunk);
emitBlockReply(
{
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
},
{
assistantMessageIndex: options?.assistantMessageIndex ?? state.assistantMessageIndex,
consumePendingToolMedia:
options?.final === true || Boolean(mediaUrls?.length || audioAsVoice),
},
);
markBlockReplyTextHandled();
};
const consumeReplyDirectives = (text: string, options?: { final?: boolean }) =>
replyDirectiveAccumulator.consume(text, options);
const consumePartialReplyDirectives = (text: string, options?: { final?: boolean }) =>
partialReplyDirectiveAccumulator.consume(text, options);
const flushBlockReplyBuffer = (options?: {
assistantMessageIndex?: number;
final?: boolean;
}): void | Promise<void> => {
if (!params.onBlockReply) {
return;
}
if (blockChunker?.hasBuffered()) {
if (options?.final) {
let pendingChunk: string | undefined;
blockChunker.drain({
force: true,
emit: (text) => {
if (pendingChunk !== undefined) {
emitBlockChunk(pendingChunk, {
assistantMessageIndex: options.assistantMessageIndex,
completeMarkdownChunk: true,
});
}
pendingChunk = text;
},
});
if (pendingChunk !== undefined) {
emitBlockChunk(pendingChunk, {
assistantMessageIndex: options.assistantMessageIndex,
completeMarkdownChunk: true,
final: true,
});
}
} else {
blockChunker.drain({ force: true, emit: (text) => emitBlockChunk(text, options) });
}
blockChunker.reset();
} else if (state.blockBuffer.length > 0) {
emitBlockChunk(state.blockBuffer, options);
state.blockBuffer = "";
}
if (options?.final) {
emitBlockChunk("", options);
}
if (pendingBlockReplyTasks.size === 0) {
return;
}
return (async () => {
while (pendingBlockReplyTasks.size > 0) {
await Promise.allSettled(pendingBlockReplyTasks);
}
})();
};
const emitReasoningStream = (text: string) => {
if (params.silentExpected) {
return;
}
const trimmed = text.trim();
if (!trimmed) {
return;
}
if (trimmed === state.lastStreamedReasoning) {
return;
}
// Compute delta: new text since the last emitted reasoning.
// Guard against non-prefix changes (e.g. trim altering earlier content).
const prior = state.lastStreamedReasoning ?? "";
const delta = trimmed.startsWith(prior) ? trimmed.slice(prior.length) : trimmed;
state.lastStreamedReasoning = trimmed;
// Emit-always: the thinking stream always reaches the bus and session
// archive. /reasoning (streamReasoning) gates only the rendering hook
// below; display surfaces (TUI showThinking, webchat isReasoning drops)
// gate presentation on their side.
emitAgentEvent({
runId: params.runId,
stream: "thinking",
data: {
text: trimmed,
delta,
},
});
// Message-tool-only delivery makes later reasoning private: once the
// user-facing reply has gone out via the message tool, the channel shows
// only what was explicitly sent, so trailing reasoning must stay out of the
// render hook — uniformly, whether the thinking block rode in on a tool call
// or arrived on its own. It still reaches the bus/archive above.
if (state.streamReasoning && !hasMessageToolOnlySourceDelivery() && params.onReasoningStream) {
runBestEffortCallback({
label: "reasoning stream",
log,
callback: () =>
params.onReasoningStream?.({
text: trimmed,
...(state.reasoningMode === "stream" ? {} : { requiresReasoningProgressOptIn: true }),
}),
});
}
};
const resetAssistantMessageState = (nextAssistantTextBaseline: number) => {
state.deltaBuffer = "";
state.thinkingTagStream = createThinkingTagStreamState();
state.blockBuffer = "";
blockChunker?.reset();
replyDirectiveAccumulator.reset();
partialReplyDirectiveAccumulator.reset();
state.blockState.thinking = false;
state.blockState.final = false;
state.blockState.inlineCode = createInlineCodeState();
state.blockState.fence = undefined;
state.blockState.reasoningInlineCode = undefined;
state.blockState.reasoningFence = undefined;
state.blockState.reasoningPendingFenceFragment = undefined;
state.blockState.finalInlineCode = undefined;
state.blockState.finalFence = undefined;
state.blockState.pendingFenceFragment = undefined;
state.blockState.pendingTagFragment = undefined;
state.partialBlockState.thinking = false;
state.partialBlockState.final = false;
state.partialBlockState.inlineCode = createInlineCodeState();
state.partialBlockState.fence = undefined;
state.partialBlockState.reasoningInlineCode = undefined;
state.partialBlockState.reasoningFence = undefined;
state.partialBlockState.reasoningPendingFenceFragment = undefined;
state.partialBlockState.finalInlineCode = undefined;
state.partialBlockState.finalFence = undefined;
state.partialBlockState.pendingFenceFragment = undefined;
state.partialBlockState.pendingTagFragment = undefined;
state.lastStreamedAssistant = undefined;
state.lastStreamedAssistantCleaned = undefined;
state.currentSourceMessagingToolHeldPartial = undefined;
state.emittedAssistantUpdate = false;
state.lastBlockReplyText = undefined;
state.lastStreamedReasoning = undefined;
state.lastReasoningSent = undefined;
state.reasoningStreamOpen = false;
state.suppressBlockChunks = false;
state.pendingAssistantUsage = undefined;
state.assistantUsageCommitted = false;
state.assistantMessageIndex += 1;
state.lastAssistantStreamContentIndex = undefined;
state.lastAssistantStreamItemId = undefined;
state.lastAssistantTextMessageIndex = -1;
state.lastAssistantTextNormalized = undefined;
state.lastAssistantTextTrimmed = undefined;
state.assistantTextBaseline = nextAssistantTextBaseline;
state.pendingAssistantReplyDirectives = undefined;
};
return {
consumePartialReplyDirectives,
consumeReplyDirectives,
emitBlockChunk,
emitReasoningStream,
flushBlockReplyBuffer,
resetAssistantMessageState,
stripBlockTags,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,507 @@
// Detached chat.send dispatch owns runtime delivery, post-dispatch persistence, and terminalization.
import { performance } from "node:perf_hooks";
import {
GATEWAY_CLIENT_CAPS,
hasGatewayClientCap,
} from "../../../packages/gateway-protocol/src/client-info.js";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
import { dispatchInboundMessageWithProjectedDispatcher } from "../../auto-reply/dispatch.js";
import type { ReplyMessageInjectionAttempt } from "../../auto-reply/reply/reply-run-registry.js";
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
import { retainGatewayRootWorkAdmissionContinuation } from "../../process/gateway-work-admission.js";
import { isOperatorUiClient } from "../../utils/message-channel.js";
import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js";
import { updateChatRunProvider } from "../chat-abort.js";
import type { ChatRunTiming } from "../server-chat-state.js";
import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js";
import type { AdmittedChatSend } from "./chat-send-admission.js";
import type { prepareChatSendAttachments } from "./chat-send-attachments.js";
import { resolveWebchatPromptCacheKey } from "./chat-send-background.js";
import { createChatSendDispatchErrorLifecycle } from "./chat-send-dispatch-errors.js";
import type { ChatSendExternalAuthorityAdmission } from "./chat-send-external-authority-contract.js";
import { finalizeAcceptedChatSendMessageInjection } from "./chat-send-message-injection.js";
import { finalizeChatSendNonAgentReplies } from "./chat-send-nonagent-finalization.js";
import {
applyChatSendReplyContextFields,
type ChatSendReplyContextFields,
} from "./chat-send-reply-context.js";
import { createChatSendReplyDispatch } from "./chat-send-reply-dispatch.js";
import type { NormalizedChatSendRequest } from "./chat-send-request.js";
import type { PreparedChatSendSession } from "./chat-send-session.js";
import { finalizeChatSendSourceReplies } from "./chat-send-source-finalization.js";
import { createChatSendTurnAdoptionLifecycle } from "./chat-send-turn-adoption.js";
import { applyChatSendManagedMedia, type prepareChatSendUserTurn } from "./chat-send-user-turn.js";
import {
emitOperatorChatSendServerTiming,
roundedChatSendTimingMs,
type ChatSendServerTimingPhase,
} from "./chat-server-timing.js";
import type { createGatewayChatUserTurnController } from "./chat-user-turn-recorder.js";
import { emitSessionsChanged } from "./session-change-event.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
type PreparedChatSendAttachments = Extract<
Awaited<ReturnType<typeof prepareChatSendAttachments>>,
{ ok: true }
>["value"];
type StartChatDispatchParams = {
admissionStartedAt: number;
admission: AdmittedChatSend;
attachments: PreparedChatSendAttachments;
client: GatewayRequestHandlerOptions["client"];
context: GatewayRequestHandlerOptions["context"];
cronCreatorAuthority: ReturnType<ChatSendExternalAuthorityAdmission["resolve"]>;
externalAuthorityAdmission: ChatSendExternalAuthorityAdmission | undefined;
injection: {
beginCapturedMessageInjection: () => ReplyMessageInjectionAttempt | undefined;
messageInjectionAttempt: ReplyMessageInjectionAttempt | undefined;
preAckReplyContextPromise: Promise<ChatSendReplyContextFields> | undefined;
replyContextFieldsPromise: Promise<ChatSendReplyContextFields> | undefined;
};
request: NormalizedChatSendRequest;
session: PreparedChatSendSession;
terminalizeRestartSafeAdmission: (terminalState: {
retryable: boolean;
status: "failed" | "killed";
}) => Promise<boolean>;
timing: {
chatSendAckedAtMs: number;
chatSendTiming: ChatRunTiming | undefined;
};
turn: ReturnType<typeof prepareChatSendUserTurn>;
userTurn: ReturnType<typeof createGatewayChatUserTurnController>;
};
export function startChatDispatch(params: StartChatDispatchParams): void {
const {
admissionStartedAt,
admission,
attachments,
client,
context,
cronCreatorAuthority,
externalAuthorityAdmission,
injection,
request,
session,
terminalizeRestartSafeAdmission,
timing,
turn,
userTurn,
} = params;
const { imageOrder } = attachments;
const {
activeRunAbort,
admittedSessionId,
chatSendTraceAttributes,
gatewayWorkAdmission,
messageInjectionTarget,
retainGatewayWorkAdmission,
restartSafeAdmission,
setReleaseGatewayRootContinuation,
} = admission;
const {
activeRunScopeKey,
agentId,
backingSessionId,
cfg,
clientRunId,
entry,
expectedLeafEntryId,
expectedRunId,
requestedSessionId,
resolvedSessionModel,
selectedAgent,
sessionKey,
} = session;
const { chatSendReceivedAtMs, clientInfo, p, reconnectResumeRequested, supportsTaskSuggestions } =
request;
const {
accountId,
ctx,
isInternalTextSlashCommandTurn,
pluginBoundMediaPromise,
queuedFollowupOwnerKey,
replyOptionImages,
replyOptionMedia,
} = turn;
const {
persist: persistGatewayUserTurnTranscript,
persistBestEffort: persistGatewayUserTurnTranscriptBestEffort,
recorder: userTurnRecorder,
} = userTurn;
const { beginCapturedMessageInjection, preAckReplyContextPromise, replyContextFieldsPromise } =
injection;
let { messageInjectionAttempt } = injection;
const { chatSendAckedAtMs, chatSendTiming } = timing;
let agentRunStarted = false;
const replyDispatch = createChatSendReplyDispatch({
accountId,
isAgentRunStarted: () => agentRunStarted,
logGateway: context.logGateway,
session,
userTurnRecorder,
});
const queuedFollowup = createChatSendTurnAdoptionLifecycle({
chatQueuedTurns: context.chatQueuedTurns,
runId: clientRunId,
controller: activeRunAbort.controller,
sessionId: backingSessionId ?? clientRunId,
sessionKey,
agentId: selectedAgent.agentId,
ownerConnId: client?.connId,
ownerDeviceId: client?.connect?.device?.id,
ownerKey: queuedFollowupOwnerKey,
...(expectedLeafEntryId !== undefined ? { originatingLeafEntryId: expectedLeafEntryId } : {}),
hasCronCreatorAuthority: cronCreatorAuthority !== undefined,
retainWorkAdmission: retainGatewayWorkAdmission,
});
const dispatchErrorLifecycle = createChatSendDispatchErrorLifecycle({
admission,
context,
isQueuedFollowupEnqueued: queuedFollowup.isEnqueued,
persistUserTurnTranscript: persistGatewayUserTurnTranscript,
session,
terminalizeRestartSafeAdmission,
userTurnRecorder,
});
const emitServerTiming = (
phase: ChatSendServerTimingPhase,
extra?: Record<string, string | number>,
dispatchStartedAtMs?: number,
) => {
emitOperatorChatSendServerTiming({
context,
client,
phase,
runId: clientRunId,
sessionKey,
agentId,
receivedAtMs: chatSendReceivedAtMs,
ackedAtMs: chatSendAckedAtMs,
dispatchStartedAtMs,
extra,
});
};
const dispatchStartedAtMs = performance.now();
if (chatSendTiming) {
chatSendTiming.dispatchStartedAtMs = dispatchStartedAtMs;
}
emitServerTiming("dispatch-started");
let firstAssistantServerTimingEmitted = false;
let acceptedMessageInjection = false;
const emitFirstAssistantServerTiming = () => {
if (firstAssistantServerTimingEmitted || chatSendTiming?.firstAssistantEventSent) {
return;
}
firstAssistantServerTimingEmitted = true;
if (chatSendTiming) {
chatSendTiming.firstAssistantEventSent = true;
}
emitServerTiming("first-assistant-event", undefined, dispatchStartedAtMs);
};
// Reserve the detached dispatch before this request releases its root. Otherwise
// its inherited ALS context becomes retired and rejects queued/session work.
setReleaseGatewayRootContinuation(retainGatewayRootWorkAdmissionContinuation() ?? undefined);
void replyDispatch
.runAgentMediaTranscript(gatewayWorkAdmission, () =>
measureDiagnosticsTimelineSpan(
"gateway.chat_send.dispatch_inbound",
async () => {
if (replyContextFieldsPromise && !preAckReplyContextPromise) {
applyChatSendReplyContextFields(ctx, await replyContextFieldsPromise);
messageInjectionAttempt = beginCapturedMessageInjection();
}
if (messageInjectionAttempt) {
const outcome = await messageInjectionAttempt.outcome;
if (outcome.status === "accepted") {
acceptedMessageInjection = true;
await finalizeAcceptedChatSendMessageInjection({
context,
ctx,
outcome,
persistUserTurnTranscriptBestEffort: persistGatewayUserTurnTranscriptBestEffort,
session,
startedAt: admissionStartedAt,
target: messageInjectionTarget!,
targetRunId: messageInjectionAttempt.targetRunId,
});
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
}
applyChatSendManagedMedia(ctx, await pluginBoundMediaPromise);
const dispatchInbound = () =>
dispatchInboundMessageWithProjectedDispatcher({
ctx,
cfg,
dispatcherOptions: replyDispatch.dispatcherOptions,
onSessionMetadataChanges: (changes) =>
changes.forEach((change) => emitSessionsChanged(context, change)),
replyOptions: {
runId: clientRunId,
...(cronCreatorAuthority
? { cronCreatorAuthorityCapability: cronCreatorAuthority }
: {}),
...(isOperatorUiClient(clientInfo)
? {
promptCacheKey: resolveWebchatPromptCacheKey({
agentId,
provider: resolvedSessionModel.provider,
model: resolvedSessionModel.model,
sessionKey: activeRunScopeKey,
}),
}
: {}),
...(supportsTaskSuggestions
? { taskSuggestionDeliveryMode: "gateway" as const }
: {}),
requestedSessionId,
...(restartSafeAdmission
? {
expectedExistingSessionId: admittedSessionId,
pinExpectedExistingSession: true,
}
: entry?.sessionId
? { expectedExistingSessionId: entry.sessionId }
: {}),
resumeRequestedSession: reconnectResumeRequested,
onSessionPrepared: (binding) => {
if (binding.sessionKey === sessionKey) {
userTurn.setAcceptedSessionId(binding.sessionId);
}
},
abortSignal: activeRunAbort.controller.signal,
// Keep a Gateway-owned cancel identity after this chat.send
// terminalizes while the prompt waits in followup/collect queue.
onFollowupQueueDisposition: (reason) => {
context.logGateway.info("chat queue turn intentionally skipped", {
runId: clientRunId,
sessionKey,
outcome: "skipped",
reason,
});
},
turnAdoptionLifecycle: queuedFollowup.lifecycle,
images: replyOptionImages,
imageOrder: imageOrder.length > 0 ? imageOrder : undefined,
media: replyOptionMedia,
thinkingLevelOverride: p.thinking,
fastModeOverride: p.fastMode,
queueModeOverride: p.queueMode,
userTurnTranscriptRecorder: userTurnRecorder,
...((messageInjectionTarget && !isInternalTextSlashCommandTurn) ||
(p.queueMode === "steer" && expectedRunId !== undefined)
? { messageInjectionAttempted: true as const }
: {}),
...(restartSafeAdmission ? { suppressNextUserMessagePersistence: true } : {}),
fastModeAutoOnSecondsOverride: p.fastAutoOnSeconds,
onAgentRunStart: (runId) => {
agentRunStarted = replyDispatch.captureAgentTranscriptStart();
emitServerTiming(
"agent-run-started",
runId !== clientRunId ? { agentRunId: runId } : undefined,
dispatchStartedAtMs,
);
const connId = typeof client?.connId === "string" ? client.connId : undefined;
const wantsToolEvents = hasGatewayClientCap(
client?.connect?.caps,
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
);
if (connId && wantsToolEvents) {
context.registerToolEventRecipient(runId, connId);
// Register for any other active runs *in the same session* so
// late-joining clients (e.g. page refresh mid-response) receive
// in-progress tool events without leaking cross-session data.
const defaultAgentId = resolveDefaultAgentId(cfg);
const selectedGlobalAgentId =
sessionKey === "global"
? (selectedAgent.agentId ?? defaultAgentId)
: undefined;
for (const [activeRunId, active] of context.chatAbortControllers) {
const activeGlobalAgentId =
active.sessionKey === "global"
? (active.agentId ?? defaultAgentId)
: undefined;
const sameSelectedGlobalAgent =
sessionKey === "global" &&
selectedGlobalAgentId !== undefined &&
activeGlobalAgentId === selectedGlobalAgentId;
const sameSession =
active.sessionKey === sessionKey &&
(sessionKey !== "global" || sameSelectedGlobalAgent);
if (activeRunId !== runId && sameSession) {
context.registerToolEventRecipient(activeRunId, connId);
}
}
}
},
onModelSelected: (modelSelection) => {
updateChatRunProvider(context.chatAbortControllers, {
runId: clientRunId,
providerId: modelSelection.provider,
authProviderId: resolveProviderIdForAuth(modelSelection.provider, {
config: cfg,
}),
});
replyDispatch.onModelSelected(modelSelection);
emitServerTiming(
"model-selected",
{
provider: modelSelection.provider,
model: modelSelection.model,
},
dispatchStartedAtMs,
);
},
},
});
const dispatchResult = await (cronCreatorAuthority && externalAuthorityAdmission
? externalAuthorityAdmission.run(
cronCreatorAuthority,
dispatchInbound,
activeRunAbort.controller.signal,
)
: dispatchInbound());
if (dispatchResult.beforeAgentRunBlocked === true) {
userTurnRecorder.markBlocked();
}
return dispatchResult;
},
{
phase: "agent-turn",
config: cfg,
attributes: chatSendTraceAttributes,
},
),
)
.then(async () => {
if (acceptedMessageInjection) {
return;
}
emitServerTiming("dispatch-completed", undefined, dispatchStartedAtMs);
const postDispatchStartedAtMs = performance.now();
await measureDiagnosticsTimelineSpan(
"gateway.chat_send.post_dispatch",
async () => {
const returnedAgentErrorPayloads = agentRunStarted
? replyDispatch.deliveredReplies
.map((entryInner) => entryInner.payload)
.filter((payload) => payload.isError)
: [];
const returnedAgentErrorMessage =
returnedAgentErrorPayloads
.map((payload) => payload.text?.trim())
.filter((text): text is string => Boolean(text))
.join(" | ") || undefined;
if (
agentRunStarted &&
returnedAgentErrorPayloads.length > 0 &&
!userTurnRecorder.hasPersisted() &&
!userTurnRecorder.isBlocked()
) {
await persistGatewayUserTurnTranscriptBestEffort();
}
if (
agentRunStarted &&
returnedAgentErrorPayloads.length === 0 &&
!userTurnRecorder.hasPersisted() &&
!userTurnRecorder.isBlocked() &&
userTurnRecorder.hasRuntimePersistencePending()
) {
await persistGatewayUserTurnTranscriptBestEffort();
}
let broadcastedSourceReplyFinal = false;
// Agent runs persist model-visible turns through SessionManager; this dispatcher owns
// live delivery. Mirroring agent finals would duplicate normal assistant turns. The
// non-agent branch has no runtime-owned turn, so it appends one before broadcasting.
if (!agentRunStarted && !queuedFollowup.isEnqueued()) {
await finalizeChatSendNonAgentReplies({
accountId,
context,
deliveredReplies: replyDispatch.deliveredReplies,
emitFirstAssistantServerTiming,
foldCommandBlocks: isInternalTextSlashCommandTurn,
persistUserTurnTranscript: persistGatewayUserTurnTranscriptBestEffort,
session,
suppressReplies: replyDispatch.hasAppendedWebchatAgentMedia(),
});
} else {
broadcastedSourceReplyFinal = await finalizeChatSendSourceReplies({
accountId,
context,
deliveredReplies: replyDispatch.deliveredReplies,
emitFirstAssistantServerTiming,
hasReturnedAgentErrorPayloads: returnedAgentErrorPayloads.length > 0,
session,
});
}
const shouldBroadcastAgentError =
returnedAgentErrorPayloads.length > 0 && !broadcastedSourceReplyFinal;
if (shouldBroadcastAgentError) {
broadcastChatError({
context,
runId: clientRunId,
sessionKey,
agentId,
errorMessage: returnedAgentErrorMessage,
});
}
if (!context.chatRunState.hasAbortMarker(clientRunId)) {
const returnedAgentError = shouldBroadcastAgentError
? errorShape(
ErrorCodes.UNAVAILABLE,
returnedAgentErrorMessage ?? "agent returned an error payload",
)
: undefined;
setGatewayDedupeEntry({
dedupe: context.dedupe,
key: `chat:${clientRunId}`,
entry: {
ts: Date.now(),
ok: !shouldBroadcastAgentError,
payload: shouldBroadcastAgentError
? {
runId: clientRunId,
status: "error" as const,
summary: returnedAgentErrorMessage ?? "agent returned an error payload",
}
: { runId: clientRunId, status: "ok" as const },
...(returnedAgentError ? { error: returnedAgentError } : {}),
},
});
}
},
{
phase: "agent-turn",
config: cfg,
attributes: chatSendTraceAttributes,
},
);
emitServerTiming(
"post-dispatch-completed",
{
postDispatchMs: roundedChatSendTimingMs(performance.now() - postDispatchStartedAtMs),
},
dispatchStartedAtMs,
);
if (queuedFollowup.isEnqueued() && !context.chatRunState.hasAbortMarker(clientRunId)) {
// Successful queue admission ends this client run. The later
// aggregate/followup owns its own run id.
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey,
agentId,
});
}
})
.catch(dispatchErrorLifecycle.handleError)
.finally(dispatchErrorLifecycle.finalize);
}
+28 -423
View File
@@ -1,57 +1,28 @@
// chat.send owns admission, ACK timing, detached dispatch, and terminalization.
// chat.send owns admission, ACK timing, and detached dispatch handoff.
import { performance } from "node:perf_hooks";
import {
GATEWAY_CLIENT_CAPS,
hasGatewayClientCap,
} from "../../../packages/gateway-protocol/src/client-info.js";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
import { dispatchInboundMessageWithProjectedDispatcher } from "../../auto-reply/dispatch.js";
import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js";
import {
emitDiagnosticsTimelineEvent,
measureDiagnosticsTimelineSpan,
} from "../../infra/diagnostics-timeline.js";
import { retainGatewayRootWorkAdmissionContinuation } from "../../process/gateway-work-admission.js";
import { isOperatorUiClient } from "../../utils/message-channel.js";
import { setGatewayDedupeEntry } from "../agent-turn/agent-job.js";
import { updateChatRunProvider } from "../chat-abort.js";
import { emitDiagnosticsTimelineEvent } from "../../infra/diagnostics-timeline.js";
import type { ChatRunTiming } from "../server-chat-state.js";
import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js";
import { terminalizeRestartSafeChatAdmission } from "./chat-restart-recovery.js";
import { startChatDispatch } from "./chat-send-agent-dispatch.js";
import { prepareChatSendAttachments } from "./chat-send-attachments.js";
import {
resolveWebchatPromptCacheKey,
scheduleChatDashboardSessionTitle,
} from "./chat-send-background.js";
import {
createChatSendDispatchErrorLifecycle,
handleChatSendSetupError,
} from "./chat-send-dispatch-errors.js";
import { scheduleChatDashboardSessionTitle } from "./chat-send-background.js";
import { handleChatSendSetupError } from "./chat-send-dispatch-errors.js";
import type { ChatSendExternalAuthorityAdmission } from "./chat-send-external-authority-contract.js";
import {
createChatSendMessageInjectionStarter,
finalizeAcceptedChatSendMessageInjection,
settleChatSendPreAckMessageInjection,
} from "./chat-send-message-injection.js";
import { finalizeChatSendNonAgentReplies } from "./chat-send-nonagent-finalization.js";
import { applyChatSendReplyContextFields } from "./chat-send-reply-context.js";
import { createChatSendReplyDispatch } from "./chat-send-reply-dispatch.js";
import { prepareAndAdmitChatSend } from "./chat-send-setup.js";
import { finalizeChatSendSourceReplies } from "./chat-send-source-finalization.js";
import { createChatSendTurnAdoptionLifecycle } from "./chat-send-turn-adoption.js";
import { applyChatSendManagedMedia, prepareChatSendUserTurn } from "./chat-send-user-turn.js";
import { prepareChatSendUserTurn } from "./chat-send-user-turn.js";
import {
chatSendAckServerTimingAttributes,
emitOperatorChatSendServerTiming,
roundedChatSendTimingMs,
shouldIncludeChatSendAckServerTiming,
type ChatSendServerTimingPhase,
} from "./chat-server-timing.js";
import { createGatewayChatUserTurnController } from "./chat-user-turn-recorder.js";
import { emitSessionsChanged } from "./session-change-event.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
export async function handleChatSend(
@@ -67,14 +38,8 @@ export async function handleChatSend(
return;
}
const { normalizedRequest, preparedSession, admitted } = setup;
const {
chatSendReceivedAtMs,
clientInfo,
supportsTaskSuggestions,
p,
systemInputProvenance,
reconnectResumeRequested,
} = normalizedRequest.value;
const { chatSendReceivedAtMs, clientInfo, p, systemInputProvenance, reconnectResumeRequested } =
normalizedRequest.value;
const {
clientRunId,
sessionLoadOptions,
@@ -85,25 +50,16 @@ export async function handleChatSend(
sessionKey,
sessionRoutingChanged,
selectedAgent,
requestedSessionId,
backingSessionId,
agentId,
activeRunScopeKey,
expectedLeafEntryId,
expectedRunId,
resolvedSessionModel,
} = preparedSession.value;
const {
activeRunAbort,
admittedSessionId,
chatSendTraceAttributes,
finishAbortedChatSend,
gatewayWorkAdmission,
lifecycleGeneration,
messageInjectionTarget,
retainGatewayWorkAdmission,
restartSafeAdmission,
setReleaseGatewayRootContinuation,
} = admitted.value;
const preparedAttachments = await prepareChatSendAttachments({
request: normalizedRequest.value,
@@ -166,7 +122,6 @@ export async function handleChatSend(
});
const {
persist: persistGatewayUserTurnTranscript,
persistBestEffort: persistGatewayUserTurnTranscriptBestEffort,
recorder: userTurnRecorder,
replyContextFieldsPromise,
} = userTurn;
@@ -217,15 +172,7 @@ export async function handleChatSend(
logGateway: context.logGateway,
userTurn,
});
const {
accountId,
ctx,
isInternalTextSlashCommandTurn,
pluginBoundMediaPromise,
queuedFollowupOwnerKey,
replyOptionImages,
replyOptionMedia,
} = preparedUserTurn;
const { ctx, isInternalTextSlashCommandTurn } = preparedUserTurn;
const beginCapturedMessageInjection = createChatSendMessageInjectionStarter({
target: messageInjectionTarget,
request: normalizedRequest.value,
@@ -314,372 +261,30 @@ export async function handleChatSend(
sessionLoadOptions,
storePath,
});
let agentRunStarted = false;
const replyDispatch = createChatSendReplyDispatch({
accountId,
isAgentRunStarted: () => agentRunStarted,
logGateway: context.logGateway,
session: preparedSession.value,
userTurnRecorder,
});
const queuedFollowup = createChatSendTurnAdoptionLifecycle({
chatQueuedTurns: context.chatQueuedTurns,
runId: clientRunId,
controller: activeRunAbort.controller,
sessionId: backingSessionId ?? clientRunId,
sessionKey,
agentId: selectedAgent.agentId,
ownerConnId: client?.connId,
ownerDeviceId: client?.connect?.device?.id,
ownerKey: queuedFollowupOwnerKey,
...(expectedLeafEntryId !== undefined ? { originatingLeafEntryId: expectedLeafEntryId } : {}),
hasCronCreatorAuthority: cronCreatorAuthority !== undefined,
retainWorkAdmission: retainGatewayWorkAdmission,
});
const dispatchErrorLifecycle = createChatSendDispatchErrorLifecycle({
startChatDispatch({
admissionStartedAt,
admission: admitted.value,
attachments: preparedAttachments.value,
client,
context,
isQueuedFollowupEnqueued: queuedFollowup.isEnqueued,
persistUserTurnTranscript: persistGatewayUserTurnTranscript,
cronCreatorAuthority,
externalAuthorityAdmission,
injection: {
beginCapturedMessageInjection,
messageInjectionAttempt,
preAckReplyContextPromise,
replyContextFieldsPromise,
},
request: normalizedRequest.value,
session: preparedSession.value,
terminalizeRestartSafeAdmission,
userTurnRecorder,
timing: {
chatSendAckedAtMs,
chatSendTiming,
},
turn: preparedUserTurn,
userTurn,
});
const emitServerTiming = (
phase: ChatSendServerTimingPhase,
extra?: Record<string, string | number>,
dispatchStartedAtMs?: number,
) => {
emitOperatorChatSendServerTiming({
context,
client,
phase,
runId: clientRunId,
sessionKey,
agentId,
receivedAtMs: chatSendReceivedAtMs,
ackedAtMs: chatSendAckedAtMs,
dispatchStartedAtMs,
extra,
});
};
const dispatchStartedAtMs = performance.now();
if (chatSendTiming) {
chatSendTiming.dispatchStartedAtMs = dispatchStartedAtMs;
}
emitServerTiming("dispatch-started");
let firstAssistantServerTimingEmitted = false;
let acceptedMessageInjection = false;
const emitFirstAssistantServerTiming = () => {
if (firstAssistantServerTimingEmitted || chatSendTiming?.firstAssistantEventSent) {
return;
}
firstAssistantServerTimingEmitted = true;
if (chatSendTiming) {
chatSendTiming.firstAssistantEventSent = true;
}
emitServerTiming("first-assistant-event", undefined, dispatchStartedAtMs);
};
// Reserve the detached dispatch before this request releases its root. Otherwise
// its inherited ALS context becomes retired and rejects queued/session work.
setReleaseGatewayRootContinuation(retainGatewayRootWorkAdmissionContinuation() ?? undefined);
void replyDispatch
.runAgentMediaTranscript(gatewayWorkAdmission, () =>
measureDiagnosticsTimelineSpan(
"gateway.chat_send.dispatch_inbound",
async () => {
if (replyContextFieldsPromise && !preAckReplyContextPromise) {
applyChatSendReplyContextFields(ctx, await replyContextFieldsPromise);
messageInjectionAttempt = beginCapturedMessageInjection();
}
if (messageInjectionAttempt) {
const outcome = await messageInjectionAttempt.outcome;
if (outcome.status === "accepted") {
acceptedMessageInjection = true;
await finalizeAcceptedChatSendMessageInjection({
context,
ctx,
outcome,
persistUserTurnTranscriptBestEffort: persistGatewayUserTurnTranscriptBestEffort,
session: preparedSession.value,
startedAt: admissionStartedAt,
target: messageInjectionTarget!,
targetRunId: messageInjectionAttempt.targetRunId,
});
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
}
applyChatSendManagedMedia(ctx, await pluginBoundMediaPromise);
const dispatchInbound = () =>
dispatchInboundMessageWithProjectedDispatcher({
ctx,
cfg,
dispatcherOptions: replyDispatch.dispatcherOptions,
onSessionMetadataChanges: (changes) =>
changes.forEach((change) => emitSessionsChanged(context, change)),
replyOptions: {
runId: clientRunId,
...(cronCreatorAuthority
? { cronCreatorAuthorityCapability: cronCreatorAuthority }
: {}),
...(isOperatorUiClient(clientInfo)
? {
promptCacheKey: resolveWebchatPromptCacheKey({
agentId,
provider: resolvedSessionModel.provider,
model: resolvedSessionModel.model,
sessionKey: activeRunScopeKey,
}),
}
: {}),
...(supportsTaskSuggestions
? { taskSuggestionDeliveryMode: "gateway" as const }
: {}),
requestedSessionId,
...(restartSafeAdmission
? {
expectedExistingSessionId: admittedSessionId,
pinExpectedExistingSession: true,
}
: entry?.sessionId
? { expectedExistingSessionId: entry.sessionId }
: {}),
resumeRequestedSession: reconnectResumeRequested,
onSessionPrepared: (binding) => {
if (binding.sessionKey === sessionKey) {
userTurn.setAcceptedSessionId(binding.sessionId);
}
},
abortSignal: activeRunAbort.controller.signal,
// Keep a Gateway-owned cancel identity after this chat.send
// terminalizes while the prompt waits in followup/collect queue.
onFollowupQueueDisposition: (reason) => {
context.logGateway.info("chat queue turn intentionally skipped", {
runId: clientRunId,
sessionKey,
outcome: "skipped",
reason,
});
},
turnAdoptionLifecycle: queuedFollowup.lifecycle,
images: replyOptionImages,
imageOrder: imageOrder.length > 0 ? imageOrder : undefined,
media: replyOptionMedia,
thinkingLevelOverride: p.thinking,
fastModeOverride: p.fastMode,
queueModeOverride: p.queueMode,
userTurnTranscriptRecorder: userTurnRecorder,
...((messageInjectionTarget && !isInternalTextSlashCommandTurn) ||
(p.queueMode === "steer" && expectedRunId !== undefined)
? { messageInjectionAttempted: true as const }
: {}),
...(restartSafeAdmission ? { suppressNextUserMessagePersistence: true } : {}),
fastModeAutoOnSecondsOverride: p.fastAutoOnSeconds,
onAgentRunStart: (runId) => {
agentRunStarted = replyDispatch.captureAgentTranscriptStart();
emitServerTiming(
"agent-run-started",
runId !== clientRunId ? { agentRunId: runId } : undefined,
dispatchStartedAtMs,
);
const connId = typeof client?.connId === "string" ? client.connId : undefined;
const wantsToolEvents = hasGatewayClientCap(
client?.connect?.caps,
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
);
if (connId && wantsToolEvents) {
context.registerToolEventRecipient(runId, connId);
// Register for any other active runs *in the same session* so
// late-joining clients (e.g. page refresh mid-response) receive
// in-progress tool events without leaking cross-session data.
const defaultAgentId = resolveDefaultAgentId(cfg);
const selectedGlobalAgentId =
sessionKey === "global"
? (selectedAgent.agentId ?? defaultAgentId)
: undefined;
for (const [activeRunId, active] of context.chatAbortControllers) {
const activeGlobalAgentId =
active.sessionKey === "global"
? (active.agentId ?? defaultAgentId)
: undefined;
const sameSelectedGlobalAgent =
sessionKey === "global" &&
selectedGlobalAgentId !== undefined &&
activeGlobalAgentId === selectedGlobalAgentId;
const sameSession =
active.sessionKey === sessionKey &&
(sessionKey !== "global" || sameSelectedGlobalAgent);
if (activeRunId !== runId && sameSession) {
context.registerToolEventRecipient(activeRunId, connId);
}
}
}
},
onModelSelected: (modelSelection) => {
updateChatRunProvider(context.chatAbortControllers, {
runId: clientRunId,
providerId: modelSelection.provider,
authProviderId: resolveProviderIdForAuth(modelSelection.provider, {
config: cfg,
}),
});
replyDispatch.onModelSelected(modelSelection);
emitServerTiming(
"model-selected",
{
provider: modelSelection.provider,
model: modelSelection.model,
},
dispatchStartedAtMs,
);
},
},
});
const dispatchResult = await (cronCreatorAuthority && externalAuthorityAdmission
? externalAuthorityAdmission.run(
cronCreatorAuthority,
dispatchInbound,
activeRunAbort.controller.signal,
)
: dispatchInbound());
if (dispatchResult.beforeAgentRunBlocked === true) {
userTurnRecorder.markBlocked();
}
return dispatchResult;
},
{
phase: "agent-turn",
config: cfg,
attributes: chatSendTraceAttributes,
},
),
)
.then(async () => {
if (acceptedMessageInjection) {
return;
}
emitServerTiming("dispatch-completed", undefined, dispatchStartedAtMs);
const postDispatchStartedAtMs = performance.now();
await measureDiagnosticsTimelineSpan(
"gateway.chat_send.post_dispatch",
async () => {
const returnedAgentErrorPayloads = agentRunStarted
? replyDispatch.deliveredReplies
.map((entryInner) => entryInner.payload)
.filter((payload) => payload.isError)
: [];
const returnedAgentErrorMessage =
returnedAgentErrorPayloads
.map((payload) => payload.text?.trim())
.filter((text): text is string => Boolean(text))
.join(" | ") || undefined;
if (
agentRunStarted &&
returnedAgentErrorPayloads.length > 0 &&
!userTurnRecorder.hasPersisted() &&
!userTurnRecorder.isBlocked()
) {
await persistGatewayUserTurnTranscriptBestEffort();
}
if (
agentRunStarted &&
returnedAgentErrorPayloads.length === 0 &&
!userTurnRecorder.hasPersisted() &&
!userTurnRecorder.isBlocked() &&
userTurnRecorder.hasRuntimePersistencePending()
) {
await persistGatewayUserTurnTranscriptBestEffort();
}
let broadcastedSourceReplyFinal = false;
// Agent runs persist model-visible turns through SessionManager; this dispatcher owns
// live delivery. Mirroring agent finals would duplicate normal assistant turns. The
// non-agent branch has no runtime-owned turn, so it appends one before broadcasting.
if (!agentRunStarted && !queuedFollowup.isEnqueued()) {
await finalizeChatSendNonAgentReplies({
accountId,
context,
deliveredReplies: replyDispatch.deliveredReplies,
emitFirstAssistantServerTiming,
foldCommandBlocks: isInternalTextSlashCommandTurn,
persistUserTurnTranscript: persistGatewayUserTurnTranscriptBestEffort,
session: preparedSession.value,
suppressReplies: replyDispatch.hasAppendedWebchatAgentMedia(),
});
} else {
broadcastedSourceReplyFinal = await finalizeChatSendSourceReplies({
accountId,
context,
deliveredReplies: replyDispatch.deliveredReplies,
emitFirstAssistantServerTiming,
hasReturnedAgentErrorPayloads: returnedAgentErrorPayloads.length > 0,
session: preparedSession.value,
});
}
const shouldBroadcastAgentError =
returnedAgentErrorPayloads.length > 0 && !broadcastedSourceReplyFinal;
if (shouldBroadcastAgentError) {
broadcastChatError({
context,
runId: clientRunId,
sessionKey,
agentId,
errorMessage: returnedAgentErrorMessage,
});
}
if (!context.chatRunState.hasAbortMarker(clientRunId)) {
const returnedAgentError = shouldBroadcastAgentError
? errorShape(
ErrorCodes.UNAVAILABLE,
returnedAgentErrorMessage ?? "agent returned an error payload",
)
: undefined;
setGatewayDedupeEntry({
dedupe: context.dedupe,
key: `chat:${clientRunId}`,
entry: {
ts: Date.now(),
ok: !shouldBroadcastAgentError,
payload: shouldBroadcastAgentError
? {
runId: clientRunId,
status: "error" as const,
summary: returnedAgentErrorMessage ?? "agent returned an error payload",
}
: { runId: clientRunId, status: "ok" as const },
...(returnedAgentError ? { error: returnedAgentError } : {}),
},
});
}
},
{
phase: "agent-turn",
config: cfg,
attributes: chatSendTraceAttributes,
},
);
emitServerTiming(
"post-dispatch-completed",
{
postDispatchMs: roundedChatSendTimingMs(performance.now() - postDispatchStartedAtMs),
},
dispatchStartedAtMs,
);
if (queuedFollowup.isEnqueued() && !context.chatRunState.hasAbortMarker(clientRunId)) {
// Successful queue admission ends this client run. The later
// aggregate/followup owns its own run id.
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey,
agentId,
});
}
})
.catch(dispatchErrorLifecycle.handleError)
.finally(dispatchErrorLifecycle.finalize);
} catch (err) {
await handleChatSendSetupError({
admission: admitted.value,