fix(codex): report harness context window; compact context popover (#121491)

* fix(codex): report harness context window as session contextTokens

Codex app-server reports model_context_window per turn. Carry it through the projector into the run result meta so session rows show the real window instead of the catalog's standard-tier input cap (272k vs 1M for gpt-5.6 models).

* improve(ui): compact chat context popover

Inline stat rows replace boxed tiles; zero-value cost rows and the whole cost section when empty are omitted; provider/model provenance lines are removed because the footer already shows the model; and the popover is narrowed to 300px.

* refactor(codex): split attempt-result assembly out of event projector

* fix(codex): seed attempt context window from startup binding

App-server v2 turn/started omits the core model_context_window, so thread/tokenUsage/updated is the only live carrier. Seed usage-less attempts from the retained startup binding rollout/session window so session metadata cannot regress to the catalog fallback.

* fix(codex): prefer native startup context window

Persisted session contextTokens has no source provenance and may contain the catalog fallback. Keep the minimum window for the conservative rotation fuse, but seed the projector from the native rollout when it is available.

* chore(plugin-sdk): regenerate api baseline (new format)

* revert(gateway): "prevent restart replay after final delivery" (broke 5 CI jobs)
This commit is contained in:
Peter Steinberger
2026-08-10 05:52:19 -07:00
committed by GitHub
parent cd7b7f639d
commit 0d4e9f3ede
91 changed files with 2545 additions and 3901 deletions
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js
import type { CodexTrajectoryRecorder } from "./trajectory.js";
export type CodexAppServerEventProjectorOptions = {
initialContextTokens?: number;
nativePostToolUseRelayEnabled?: boolean;
onNativeToolResultRecorded?: () => void | Promise<void>;
prepareNativeMcpAppResultDetails?: (item: CodexThreadItem) => Promise<unknown>;
@@ -0,0 +1,231 @@
import {
classifyAgentHarnessTerminalOutcome,
type EmbeddedRunAttemptParams,
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
attemptTerminal,
type AttemptFailureSource,
type EmbeddedRunAttemptResult,
} from "./attempt-terminal.js";
import type { CodexAssistantProjection } from "./event-projector-assistant.js";
import type { CodexGeneratedMediaProjection } from "./event-projector-media.js";
import type { CodexNativeToolLifecycleProjector } from "./event-projector-native-tool-lifecycle.js";
import type { CodexReasoningProjection } from "./event-projector-reasoning.js";
import { buildCodexMessagesSnapshot } from "./event-projector-snapshot.js";
import type { CodexToolProgressProjection } from "./event-projector-tool-progress.js";
import type { CodexToolTranscriptProjection } from "./event-projector-tool-transcript.js";
import type { CodexResponseCompletionProjection } from "./event-projector-usage.js";
import type { CodexTurn } from "./protocol.js";
export type CodexAppServerToolTelemetry = {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
successfulCronAdds?: number;
} & Pick<EmbeddedRunAttemptResult, "acceptedSessionSpawns">;
type CodexAttemptResultInput = {
runParams: EmbeddedRunAttemptParams;
turnId: string;
upstreamUserText: string | undefined;
completedTurn: CodexTurn | undefined;
promptError: unknown;
promptErrorSource: AttemptFailureSource | null;
synthesizedMissingToolResultError: string | null;
recordSynthesizedMissingToolResultError: (error: string) => void;
aborted: boolean;
tokenUsage: EmbeddedRunAttemptResult["attemptUsage"];
contextTokens: number | undefined;
completedCompactionCount: number;
activeItemCount: number;
completedItemCount: number;
guardianReviewCount: number;
toolTelemetry: CodexAppServerToolTelemetry;
yieldDetected: boolean | undefined;
nativeToolLifecycleProjection: Pick<CodexNativeToolLifecycleProjector, "finalizeActive">;
assistantProjection: Pick<
CodexAssistantProjection,
| "collectAssistantTexts"
| "collectCommentaryMessages"
| "createAssistantMessage"
| "createAssistantMirrorMessage"
| "createCurrentAttemptAssistantMessage"
| "hasAssistantItemTextForSynthesis"
>;
reasoningProjection: Pick<CodexReasoningProjection, "planText" | "reasoningText">;
responseCompletions: Pick<CodexResponseCompletionProjection, "modelIterations" | "usage">;
toolTranscriptProjection: Pick<
CodexToolTranscriptProjection,
"synthesizeMissingToolResults" | "transcriptMessages"
>;
toolProgressProjection: Pick<
CodexToolProgressProjection,
"hasPotentialSideEffects" | "lastToolError" | "toolMetas"
>;
generatedMediaProjection: Pick<
CodexGeneratedMediaProjection,
"buildHostOwnedMediaUrls" | "buildToolMediaUrls" | "hasGeneratedMedia"
>;
};
export function buildCodexAttemptResult(
input: CodexAttemptResultInput,
): EmbeddedRunAttemptResult & { terminalTurnId: string } {
// Result construction runs after the notification queue drains. Close any
// tool lacking a terminal item so audit consumers never retain an open action.
input.nativeToolLifecycleProjection.finalizeActive();
const assistantTexts = input.assistantProjection.collectAssistantTexts();
const commentaryMessages = input.assistantProjection.collectCommentaryMessages();
const reasoningText = input.reasoningProjection.reasoningText();
const planText = input.reasoningProjection.planText();
// A terminal timeout must not publish exact usage, but the timeout watcher
// can still recover a completed assistant. Keep the snapshot masked until
// recovery clears the abort instead of destroying it in markTimedOut().
const completedUsage = input.responseCompletions.usage ?? input.tokenUsage;
const projectedUsage = input.aborted ? input.tokenUsage : completedUsage;
const hasAssistantItemText = input.assistantProjection.hasAssistantItemTextForSynthesis();
const legacyFailClosed =
!input.completedTurn || input.completedTurn.status !== "completed" || hasAssistantItemText;
const hasDeliverableAssistantOnCompletedTurn =
input.completedTurn?.status === "completed" &&
assistantTexts.some((text) => text.trim().length > 0);
const synthesizedMissingToolResultError =
input.toolTranscriptProjection.synthesizeMissingToolResults({
synthesize: legacyFailClosed,
// Preserve audit synthesis on every path, but completed answers must not
// promote bookkeeping gaps into user-visible terminal failure evidence.
terminalDisposition: input.aborted
? "tool_error"
: hasDeliverableAssistantOnCompletedTurn
? "diagnostic_only"
: "prompt_error",
});
const storedMissingToolResultError =
synthesizedMissingToolResultError ?? input.synthesizedMissingToolResultError;
let promptErrorSource = input.promptErrorSource;
if (synthesizedMissingToolResultError) {
input.recordSynthesizedMissingToolResultError(synthesizedMissingToolResultError);
promptErrorSource = promptErrorSource ?? "prompt";
}
const assistantMessageOptions = {
tokenUsage: projectedUsage,
aborted: input.aborted,
promptError: input.promptError,
};
const lastAssistant = assistantTexts.length
? input.assistantProjection.createAssistantMessage(
assistantTexts.join("\n\n"),
assistantMessageOptions,
)
: undefined;
const currentAttemptAssistant =
input.assistantProjection.createCurrentAttemptAssistantMessage(assistantMessageOptions);
// Each snapshot entry is tagged with a stable mirror identity of the
// shape `${turnId}:${kind}`. The mirror's idempotency key is derived
// from this identity rather than from snapshot position or content
// hash, so:
// - Re-mirror of the same turn (retry) → same identity → no-op.
// - Re-emit of a prior turn's entry into a later turn's snapshot
// (the cross-turn drift mode named in #77012) → original identity
// is preserved → on-disk key still matches → also a no-op.
// - Two distinct turns where the user repeats verbatim content →
// distinct turnIds → distinct identities → both kept.
// Codex owns the canonical thread. These mirror records keep enough local
// context for OpenClaw history, search, and future harness switching.
const messagesSnapshot = buildCodexMessagesSnapshot({
runParams: input.runParams,
turnId: input.turnId,
upstreamUserText: input.upstreamUserText,
reasoningText,
planText,
commentaryMessages,
toolMessages: input.toolTranscriptProjection.transcriptMessages,
lastAssistant,
createAssistantMirrorMessage: (title, text) =>
input.assistantProjection.createAssistantMirrorMessage(title, text),
});
const turnFailed = input.completedTurn?.status === "failed";
const promptError =
input.promptError ??
storedMissingToolResultError ??
(turnFailed ? (input.completedTurn?.error?.message ?? "codex app-server turn failed") : null);
const agentHarnessResultClassification = classifyAgentHarnessTerminalOutcome({
assistantTexts,
reasoningText,
planText,
promptError,
turnCompleted: Boolean(input.completedTurn),
});
const toolMetas = input.toolProgressProjection.toolMetas;
const hadPotentialSideEffects =
input.toolTelemetry.didSendViaMessagingTool ||
Boolean(
input.toolTelemetry.successfulCronAdds || input.toolTelemetry.acceptedSessionSpawns?.length,
) ||
input.generatedMediaProjection.hasGeneratedMedia() ||
input.toolProgressProjection.hasPotentialSideEffects;
return {
terminal: attemptTerminal.normalize({
aborted: input.aborted,
promptError,
promptErrorSource: promptError ? promptErrorSource || "prompt" : null,
}),
sessionIdUsed: input.runParams.sessionId,
terminalTurnId: input.turnId,
...(agentHarnessResultClassification ? { agentHarnessResultClassification } : {}),
bootstrapPromptWarningSignaturesSeen: input.runParams.bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature: input.runParams.bootstrapPromptWarningSignature,
...(input.responseCompletions.modelIterations > 0
? { modelIterations: input.responseCompletions.modelIterations }
: {}),
messagesSnapshot,
assistantTexts,
toolMetas,
lastAssistant,
currentAttemptAssistant,
...(input.toolProgressProjection.lastToolError
? { lastToolError: input.toolProgressProjection.lastToolError }
: {}),
didSendViaMessagingTool: input.toolTelemetry.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
input.toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
messagingToolSentTexts: input.toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: input.toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: input.toolTelemetry.messagingToolSentTargets,
messagingToolSourceReplyPayloads: input.toolTelemetry.messagingToolSourceReplyPayloads ?? [],
heartbeatToolResponse: input.toolTelemetry.heartbeatToolResponse,
toolMediaUrls: input.generatedMediaProjection.buildToolMediaUrls(input.toolTelemetry),
hostOwnedToolMediaUrls: input.generatedMediaProjection.buildHostOwnedMediaUrls(
input.toolTelemetry,
),
toolAudioAsVoice: input.toolTelemetry.toolAudioAsVoice,
successfulCronAdds: input.toolTelemetry.successfulCronAdds,
acceptedSessionSpawns: input.toolTelemetry.acceptedSessionSpawns,
cloudCodeAssistFormatError: false,
contextTokens: input.contextTokens,
attemptUsage: projectedUsage,
...(input.completedCompactionCount > 0
? { compactionCount: input.completedCompactionCount }
: {}),
replayMetadata: {
hadPotentialSideEffects,
replaySafe: !hadPotentialSideEffects,
},
itemLifecycle: {
startedCount: input.activeItemCount + input.completedItemCount,
completedCount: input.completedItemCount,
activeCount: input.activeItemCount,
},
yieldDetected: input.yieldDetected || false,
didSendDeterministicApprovalPrompt: input.guardianReviewCount > 0 ? false : undefined,
};
}
@@ -1,17 +1,13 @@
// Codex plugin module implements event projector behavior.
import {
classifyAgentHarnessTerminalOutcome,
embeddedAgentLog,
emitAgentEvent as emitGlobalAgentEvent,
runAgentHarnessAfterCompactionHook,
runAgentHarnessBeforeCompactionHook,
type BeforeToolCallFailureDisposition,
type EmbeddedRunAttemptParams,
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { attemptTerminal, type AttemptFailureSource } from "./attempt-terminal.js";
import type { AttemptFailureSource } from "./attempt-terminal.js";
import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js";
import { CodexAssistantProjection } from "./event-projector-assistant.js";
import { CodexProjectionDiagnostics } from "./event-projector-diagnostics.js";
@@ -26,7 +22,10 @@ import { CodexGeneratedMediaProjection } from "./event-projector-media.js";
import { CodexNativeToolLifecycleProjector } from "./event-projector-native-tool-lifecycle.js";
import type { CodexAppServerEventProjectorOptions } from "./event-projector-options.js";
import { CodexReasoningProjection } from "./event-projector-reasoning.js";
import { buildCodexMessagesSnapshot } from "./event-projector-snapshot.js";
import {
buildCodexAttemptResult,
type CodexAppServerToolTelemetry,
} from "./event-projector-result.js";
import { CodexToolProgressProjection } from "./event-projector-tool-progress.js";
import { CodexToolTranscriptProjection } from "./event-projector-tool-transcript.js";
import {
@@ -62,19 +61,6 @@ export { shouldEmitTranscriptToolProgress } from "./event-projector-tool-progres
type ApprovalFailure = Exclude<BeforeToolCallFailureDisposition, "blocked">;
type CodexAppServerToolTelemetry = {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
successfulCronAdds?: number;
} & Pick<EmbeddedRunAttemptResult, "acceptedSessionSpawns">;
export class CodexAppServerEventProjector {
private readonly assistantProjection: CodexAssistantProjection;
private readonly reasoningProjection: CodexReasoningProjection;
@@ -95,6 +81,7 @@ export class CodexAppServerEventProjector {
private synthesizedMissingToolResultError: string | null = null;
private aborted = false;
private tokenUsage: ReturnType<typeof normalizeCodexThreadTokenUsage>;
private contextTokens: number | undefined;
private readonly responseCompletions = new CodexResponseCompletionProjection();
private completedCompactionCount = 0;
private lastTranscriptTimestamp = 0;
@@ -105,6 +92,7 @@ export class CodexAppServerEventProjector {
private readonly turnId: string,
private readonly options: CodexAppServerEventProjectorOptions = {},
) {
this.contextTokens = options.initialContextTokens;
this.diagnostics = new CodexProjectionDiagnostics(threadId, turnId);
this.nativeToolLifecycleProjector = new CodexNativeToolLifecycleProjector(
params,
@@ -276,7 +264,10 @@ export class CodexAppServerEventProjector {
params,
this.tokenUsage,
(usage) => (this.tokenUsage = usage),
(data) => this.emitAgentEvent({ stream: "codex_app_server.usage", data }),
(data) => {
this.contextTokens = data.modelContextWindow ?? this.contextTokens;
this.emitAgentEvent({ stream: "codex_app_server.usage", data });
},
);
break;
case "turn/completed":
@@ -319,147 +310,35 @@ export class CodexAppServerEventProjector {
toolTelemetry: CodexAppServerToolTelemetry,
options?: { yieldDetected?: boolean },
): EmbeddedRunAttemptResult & { terminalTurnId: string } {
// Result construction runs after the notification queue drains. Close any
// tool lacking a terminal item so audit consumers never retain an open action.
this.nativeToolLifecycleProjector.finalizeActive();
const assistantTexts = this.assistantProjection.collectAssistantTexts();
const commentaryMessages = this.assistantProjection.collectCommentaryMessages();
const reasoningText = this.reasoningProjection.reasoningText();
const planText = this.reasoningProjection.planText();
// A terminal timeout must not publish exact usage, but the timeout watcher
// can still recover a completed assistant. Keep the snapshot masked until
// recovery clears the abort instead of destroying it in markTimedOut().
const completedUsage = this.responseCompletions.usage ?? this.tokenUsage;
const projectedUsage = this.aborted ? this.tokenUsage : completedUsage;
const hasAssistantItemText = this.assistantProjection.hasAssistantItemTextForSynthesis();
const legacyFailClosed =
!this.completedTurn || this.completedTurn.status !== "completed" || hasAssistantItemText;
const hasDeliverableAssistantOnCompletedTurn =
this.completedTurn?.status === "completed" &&
assistantTexts.some((text) => text.trim().length > 0);
const synthesizedMissingToolResultError =
this.toolTranscriptProjection.synthesizeMissingToolResults({
synthesize: legacyFailClosed,
// Preserve audit synthesis on every path, but completed answers must not
// promote bookkeeping gaps into user-visible terminal failure evidence.
terminalDisposition: this.aborted
? "tool_error"
: hasDeliverableAssistantOnCompletedTurn
? "diagnostic_only"
: "prompt_error",
});
if (synthesizedMissingToolResultError) {
this.synthesizedMissingToolResultError = synthesizedMissingToolResultError;
this.promptErrorSource = this.promptErrorSource ?? "prompt";
}
const assistantMessageOptions = {
tokenUsage: projectedUsage,
aborted: this.aborted,
promptError: this.promptError,
};
const lastAssistant = assistantTexts.length
? this.assistantProjection.createAssistantMessage(
assistantTexts.join("\n\n"),
assistantMessageOptions,
)
: undefined;
const currentAttemptAssistant =
this.assistantProjection.createCurrentAttemptAssistantMessage(assistantMessageOptions);
// Each snapshot entry is tagged with a stable mirror identity of the
// shape `${turnId}:${kind}`. The mirror's idempotency key is derived
// from this identity rather than from snapshot position or content
// hash, so:
// - Re-mirror of the same turn (retry) → same identity → no-op.
// - Re-emit of a prior turn's entry into a later turn's snapshot
// (the cross-turn drift mode named in #77012) → original identity
// is preserved → on-disk key still matches → also a no-op.
// - Two distinct turns where the user repeats verbatim content →
// distinct turnIds → distinct identities → both kept.
// Codex owns the canonical thread. These mirror records keep enough local
// context for OpenClaw history, search, and future harness switching.
const messagesSnapshot = buildCodexMessagesSnapshot({
return buildCodexAttemptResult({
runParams: this.params,
turnId: this.turnId,
upstreamUserText: this.options.upstreamUserText,
reasoningText,
planText,
commentaryMessages,
toolMessages: this.toolTranscriptProjection.transcriptMessages,
lastAssistant,
createAssistantMirrorMessage: (title, text) =>
this.assistantProjection.createAssistantMirrorMessage(title, text),
});
const turnFailed = this.completedTurn?.status === "failed";
const promptError =
this.promptError ??
this.synthesizedMissingToolResultError ??
(turnFailed ? (this.completedTurn?.error?.message ?? "codex app-server turn failed") : null);
const agentHarnessResultClassification = classifyAgentHarnessTerminalOutcome({
assistantTexts,
reasoningText,
planText,
promptError,
turnCompleted: Boolean(this.completedTurn),
});
const toolMetas = this.toolProgressProjection.toolMetas;
const hadPotentialSideEffects =
toolTelemetry.didSendViaMessagingTool ||
Boolean(toolTelemetry.successfulCronAdds || toolTelemetry.acceptedSessionSpawns?.length) ||
this.generatedMediaProjection.hasGeneratedMedia() ||
this.toolProgressProjection.hasPotentialSideEffects;
return {
terminal: attemptTerminal.normalize({
aborted: this.aborted,
promptError,
promptErrorSource: promptError ? this.promptErrorSource || "prompt" : null,
}),
sessionIdUsed: this.params.sessionId,
terminalTurnId: this.turnId,
...(agentHarnessResultClassification ? { agentHarnessResultClassification } : {}),
bootstrapPromptWarningSignaturesSeen: this.params.bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature: this.params.bootstrapPromptWarningSignature,
...(this.responseCompletions.modelIterations > 0
? { modelIterations: this.responseCompletions.modelIterations }
: {}),
messagesSnapshot,
assistantTexts,
toolMetas,
lastAssistant,
currentAttemptAssistant,
...(this.toolProgressProjection.lastToolError
? { lastToolError: this.toolProgressProjection.lastToolError }
: {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
messagingToolSourceReplyPayloads: toolTelemetry.messagingToolSourceReplyPayloads ?? [],
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
toolMediaUrls: this.generatedMediaProjection.buildToolMediaUrls(toolTelemetry),
hostOwnedToolMediaUrls: this.generatedMediaProjection.buildHostOwnedMediaUrls(toolTelemetry),
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
successfulCronAdds: toolTelemetry.successfulCronAdds,
acceptedSessionSpawns: toolTelemetry.acceptedSessionSpawns,
cloudCodeAssistFormatError: false,
attemptUsage: projectedUsage,
...(this.completedCompactionCount > 0
? { compactionCount: this.completedCompactionCount }
: {}),
replayMetadata: {
hadPotentialSideEffects,
replaySafe: !hadPotentialSideEffects,
completedTurn: this.completedTurn,
promptError: this.promptError,
promptErrorSource: this.promptErrorSource,
synthesizedMissingToolResultError: this.synthesizedMissingToolResultError,
recordSynthesizedMissingToolResultError: (error) => {
this.synthesizedMissingToolResultError = error;
this.promptErrorSource = this.promptErrorSource ?? "prompt";
},
itemLifecycle: {
startedCount: this.activeItemIds.size + this.completedItemIds.size,
completedCount: this.completedItemIds.size,
activeCount: this.activeItemIds.size,
},
yieldDetected: options?.yieldDetected || false,
didSendDeterministicApprovalPrompt:
this.eventProjection.guardianReviewCount > 0 ? false : undefined,
};
aborted: this.aborted,
tokenUsage: this.tokenUsage,
contextTokens: this.contextTokens,
completedCompactionCount: this.completedCompactionCount,
activeItemCount: this.activeItemIds.size,
completedItemCount: this.completedItemIds.size,
guardianReviewCount: this.eventProjection.guardianReviewCount,
toolTelemetry,
yieldDetected: options?.yieldDetected,
nativeToolLifecycleProjection: this.nativeToolLifecycleProjector,
assistantProjection: this.assistantProjection,
reasoningProjection: this.reasoningProjection,
responseCompletions: this.responseCompletions,
toolTranscriptProjection: this.toolTranscriptProjection,
toolProgressProjection: this.toolProgressProjection,
generatedMediaProjection: this.generatedMediaProjection,
});
}
recordDynamicToolCall(params: { callId: string; tool: string; arguments?: JsonValue }): void {
@@ -19,10 +19,24 @@ import {
registerCodexEventProjectorTestLifecycle();
describe("CodexAppServerEventProjector usage projection", () => {
it("keeps the startup harness window when no token-usage update arrives", async () => {
const projector = await createProjector(undefined, { initialContextTokens: 1_050_000 });
await projector.handleNotification(agentMessageDelta("done"));
await projector.handleNotification(turnCompleted());
expect(projector.buildResult(buildEmptyToolTelemetry())).toMatchObject({
contextTokens: 1_050_000,
});
});
it("emits native context-window and prompt-token snapshots", async () => {
const params = await createParams();
const onAgentEvent = vi.fn();
const projector = await createProjector({ ...params, onAgentEvent });
const projector = await createProjector(
{ ...params, onAgentEvent },
{ initialContextTokens: 1_050_000 },
);
await projector.handleNotification(
forCurrentTurn("thread/tokenUsage/updated", {
@@ -53,6 +67,9 @@ describe("CodexAppServerEventProjector usage projection", () => {
reasoningOutputTokens: 4,
},
});
expect(projector.buildResult(buildEmptyToolTelemetry())).toMatchObject({
contextTokens: 875_900,
});
});
it("ignores cumulative thread usage after exact response usage", async () => {
@@ -95,6 +95,7 @@ export async function activateCodexAttemptTurn(
resourceState.thread.threadId,
activeTurnId,
{
initialContextTokens: connection.mutable.startupContextTokens,
nativePostToolUseRelayEnabled:
resourceState.nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
resourceState.nativeHookRelay.shouldRelayEvent("post_tool_use"),
@@ -359,7 +359,7 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu
params.abortSignal?.addEventListener("abort", abortFromUpstream, { once: true });
}
const startupBindingBeforeRotation = startupBinding;
startupBinding = await rotateOversizedCodexAppServerStartupBinding({
const startupBindingResolution = await rotateOversizedCodexAppServerStartupBinding({
binding: startupBinding,
bindingStore,
identity: bindingIdentity,
@@ -369,6 +369,7 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu
config: params.config,
contextEngineActive: Boolean(activeContextEngine),
});
startupBinding = startupBindingResolution.binding;
const initialInactiveThreadBootstrapBindingForcedFreshStart =
initialStartupBindingHadInactiveThreadBootstrap && !startupBinding?.threadId;
preDynamicStartupStages.mark("rotate-binding");
@@ -396,7 +397,11 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu
configuredEvents: options.nativeHookRelay?.events,
appServer,
});
const mutable = { startupBinding, pluginAppServer: appServer };
const mutable = {
startupBinding,
startupContextTokens: startupBindingResolution.startupContextTokens,
pluginAppServer: appServer,
};
const resolveRuntimeOptionsForCurrentBinding = (selection: {
modelProvider?: string;
model?: string;
@@ -423,7 +423,7 @@ export async function prepareCodexAttemptPrompt(context: CodexAttemptContext) {
}
const previousThreadId = binding.threadId;
const hadInactiveThreadBootstrapBinding = isInactiveThreadBootstrapBinding(binding);
mutable.startupBinding = await rotateOversizedCodexAppServerStartupBinding({
const startupBindingResolution = await rotateOversizedCodexAppServerStartupBinding({
binding,
bindingStore,
identity: bindingIdentity,
@@ -437,6 +437,8 @@ export async function prepareCodexAttemptPrompt(context: CodexAttemptContext) {
developerInstructions: buildRenderedCodexDeveloperInstructions(),
}),
});
mutable.startupBinding = startupBindingResolution.binding;
mutable.startupContextTokens = startupBindingResolution.startupContextTokens;
if (mutable.startupBinding?.threadId) {
return;
}
@@ -10,7 +10,7 @@ import {
} from "./session-binding.test-helpers.js";
import { rotateOversizedCodexAppServerStartupBinding as rotateStartupBindingImpl } from "./startup-binding.js";
function rotateOversizedCodexAppServerStartupBinding(
function resolveCodexAppServerStartupBinding(
params: Omit<Parameters<typeof rotateStartupBindingImpl>[0], "bindingStore" | "identity">,
) {
return rotateStartupBindingImpl({
@@ -20,6 +20,12 @@ function rotateOversizedCodexAppServerStartupBinding(
});
}
async function rotateOversizedCodexAppServerStartupBinding(
params: Omit<Parameters<typeof rotateStartupBindingImpl>[0], "bindingStore" | "identity">,
) {
return (await resolveCodexAppServerStartupBinding(params)).binding;
}
describe("Codex app-server startup binding", () => {
let tempDir: string;
@@ -853,12 +859,12 @@ describe("Codex app-server startup binding", () => {
expect(savedBinding).toBeUndefined();
});
it("keeps native rollouts above the old guard when Codex still has context window headroom", async () => {
it("prefers the native rollout window over a stale persisted context fallback", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
await writeSessionRecord(sessionFile, { totalTokens: 12_000, contextTokens: 272_000 });
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
await fs.mkdir(rolloutDir, { recursive: true });
await fs.writeFile(
@@ -870,13 +876,13 @@ describe("Codex app-server startup binding", () => {
last_token_usage: {
total_tokens: 86_000,
},
model_context_window: 272_000,
model_context_window: 1_050_000,
},
},
})}\n`,
);
const binding = await rotateOversizedCodexAppServerStartupBinding({
const resolution = await resolveCodexAppServerStartupBinding({
binding: await readCodexAppServerBinding(sessionFile),
sessionFile,
agentDir,
@@ -891,7 +897,8 @@ describe("Codex app-server startup binding", () => {
} as never,
});
expect(binding?.threadId).toBe("thread-existing");
expect(resolution.binding?.threadId).toBe("thread-existing");
expect(resolution.startupContextTokens).toBe(1_050_000);
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-existing");
});
@@ -421,15 +421,18 @@ export async function rotateOversizedCodexAppServerStartupBinding(params: {
config: EmbeddedRunAttemptParams["config"] | undefined;
contextEngineActive?: boolean;
projectedTurnTokens?: number;
}): Promise<CodexAppServerThreadBinding | undefined> {
}): Promise<{
binding: CodexAppServerThreadBinding | undefined;
startupContextTokens?: number;
}> {
const binding = params.binding;
if (!binding?.threadId) {
return binding;
return { binding };
}
// Native Codex owns compaction for supervised threads. Clearing this private
// scope marker would silently move the next turn back to the agent runtime.
if (binding.connectionScope === "supervision") {
return binding;
return { binding };
}
const sessionRecord = await readCodexSessionRecordForSessionFile(params.sessionFile);
const rolloutFiles = await listCodexAppServerRolloutFilesForThread(
@@ -474,7 +477,7 @@ export async function rotateOversizedCodexAppServerStartupBinding(params: {
kind: "clear",
threadId: binding.threadId,
});
return undefined;
return { binding: undefined };
}
}
const nativeTokenSnapshots = await Promise.all(
@@ -495,8 +498,12 @@ export async function rotateOversizedCodexAppServerStartupBinding(params: {
? Math.floor(sessionRecord.contextTokens)
: undefined;
const reserveTokens = resolveCodexAppServerNativeThreadReserveTokens(params.config);
const rotationContextTokens = minFiniteNumber([
nativeModelContextWindow,
sessionModelContextWindow,
]);
const maxTokens = resolveCodexAppServerNativeThreadTokenFuse({
modelContextWindow: minFiniteNumber([nativeModelContextWindow, sessionModelContextWindow]),
modelContextWindow: rotationContextTokens,
reserveTokens,
projectedTurnTokens: params.projectedTurnTokens,
});
@@ -527,7 +534,13 @@ export async function rotateOversizedCodexAppServerStartupBinding(params: {
kind: "clear",
threadId: binding.threadId,
});
return undefined;
return { binding: undefined };
}
return binding;
// Session metadata has no source provenance and may contain a catalog fallback.
// Prefer the native rollout for result seeding; keep the minimum only for rotation safety.
const startupContextTokens = nativeModelContextWindow ?? sessionModelContextWindow;
return {
binding,
...(startupContextTokens ? { startupContextTokens } : {}),
};
}
@@ -323,7 +323,6 @@ export async function dispatchDiscordComponentEvent(params: {
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
mediaLocalRoots,
kind: info.kind,
bindPendingFinalDelivery: info.bindPendingFinalDelivery,
});
if (result.visibleReplySent) {
replyReference.markSent();
@@ -271,10 +271,7 @@ async function processDiscordMessageInner(
const deliverDiscordPayload = async (
payload: ReplyPayload,
info: {
kind: ReplyDispatchKind;
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T;
},
info: { kind: ReplyDispatchKind },
options?: {
allowFallbackOnlyToolWarning?: boolean;
allowProgressBlock?: boolean;
@@ -331,7 +328,6 @@ async function processDiscordMessageInner(
threadBindings,
mediaLocalRoots,
kind: "block",
bindPendingFinalDelivery: info.bindPendingFinalDelivery,
});
if (result.visibleReplySent) {
replyReference.markSent();
@@ -488,7 +484,6 @@ async function processDiscordMessageInner(
mediaLocalRoots,
allowedMentions,
kind: info.kind,
bindPendingFinalDelivery: info.bindPendingFinalDelivery,
});
return deliveryResult.visibleReplySent;
},
@@ -547,7 +542,6 @@ async function processDiscordMessageInner(
threadBindings,
mediaLocalRoots,
kind: info.kind,
bindPendingFinalDelivery: info.bindPendingFinalDelivery,
});
if (!result.visibleReplySent) {
return result;
@@ -229,16 +229,13 @@ export async function deliverDiscordReply(params: {
mediaLocalRoots?: readonly string[];
allowedMentions?: DiscordAllowedMentions;
kind: "tool" | "block" | "final";
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T;
}) {
void params.runtime;
const delivery = resolveDiscordDeliveryOptions(params);
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, {
kind: params.kind,
})
.map(formatDiscordReasoningPayload)
.map((payload) => params.bindPendingFinalDelivery?.(payload) ?? payload);
}).map(formatDiscordReasoningPayload);
if (payloads.length === 0) {
return {
visibleReplySent: false,
@@ -86,7 +86,9 @@ export function sanitizeDiscordFrontChannelReplyPayloads(
: sanitizeDiscordFrontChannelText(payload.text)
: payload.text;
const nextPayload =
safeText === payload.text ? payload : { ...payload, text: safeText || undefined };
safeText === payload.text
? payload
: ({ ...payload, text: safeText || undefined } as ReplyPayload);
const nextParts = resolveSendableOutboundReplyParts(nextPayload);
if (!nextParts.hasContent && !hasNonTextReplyPayloadContent(nextPayload)) {
continue;
+11 -29
View File
@@ -65,7 +65,6 @@ async function maybeSendDiscordWebhookText(params: {
accountId?: string | null;
identity?: OutboundIdentity;
replyToId?: string | null;
onPlatformSendDispatch?: () => Promise<void>;
}): Promise<{ messageId: string; channelId: string } | null> {
if (params.threadId == null) {
return null;
@@ -97,7 +96,6 @@ async function maybeSendDiscordWebhookText(params: {
replyTo: params.replyToId ?? undefined,
username: persona.username,
avatarUrl: persona.avatarUrl,
onPlatformSendDispatch: params.onPlatformSendDispatch,
});
return result;
}
@@ -132,7 +130,6 @@ async function resolveDiscordOutboundMessageSend(params: DiscordOutboundMessageC
await params.onDeliveryResult?.(attachChannelToResult("discord", result));
}
: undefined,
onPlatformSendDispatch: params.onPlatformSendDispatch,
},
};
}
@@ -176,29 +173,16 @@ export const discordOutbound: ChannelOutboundAdapter = {
channel: "discord",
sendText: async (ctx) => {
if (!ctx.silent) {
let webhookSelected = false;
try {
const webhookResult = await maybeSendDiscordWebhookText({
cfg: ctx.cfg,
text: ctx.text,
threadId: ctx.threadId,
accountId: ctx.accountId,
identity: ctx.identity,
replyToId: ctx.replyToId,
onPlatformSendDispatch: ctx.onPlatformSendDispatch
? async () => {
webhookSelected = true;
await ctx.onPlatformSendDispatch?.();
}
: undefined,
});
if (webhookResult) {
return webhookResult;
}
} catch (error) {
if (webhookSelected) {
throw error;
}
const webhookResult = await maybeSendDiscordWebhookText({
cfg: ctx.cfg,
text: ctx.text,
threadId: ctx.threadId,
accountId: ctx.accountId,
identity: ctx.identity,
replyToId: ctx.replyToId,
}).catch(() => null);
if (webhookResult) {
return webhookResult;
}
}
const { send, target, options } = await resolveDiscordOutboundMessageSend(ctx);
@@ -218,7 +202,6 @@ export const discordOutbound: ChannelOutboundAdapter = {
mediaAccess: ctx.mediaAccess,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
onPlatformSendDispatch: ctx.onPlatformSendDispatch,
});
}
const mediaOptions = {
@@ -252,14 +235,13 @@ export const discordOutbound: ChannelOutboundAdapter = {
}
return await send(target, ctx.text, mediaOptions);
},
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, onPlatformSendDispatch }) =>
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) =>
await (
await loadDiscordSendRuntime()
).sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
onPlatformSendDispatch,
}),
}),
afterDeliverPayload: async ({ cfg, target, payload, results }) => {
@@ -57,7 +57,6 @@ function resolveDiscordDeliveryOptions(
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
onPlatformSendDispatch: ctx.onPlatformSendDispatch,
};
}
@@ -172,7 +172,6 @@ type DiscordComponentSendOpts = {
allowedMentions?: DiscordAllowedMentions;
/** Persist the concrete platform send before component bookkeeping can fail. */
onDeliveryResult?: (result: DiscordSendResult) => Promise<void> | void;
onPlatformSendDispatch?: () => Promise<void>;
};
export function registerBuiltDiscordComponentMessage(params: {
@@ -292,7 +291,6 @@ export async function sendDiscordComponentMessage(
tableMode: opts.tableMode,
chunkMode: opts.chunkMode,
onDeliveryResult: opts.onDeliveryResult,
onPlatformSendDispatch: opts.onPlatformSendDispatch,
...(opts.suppressEmbeds === undefined ? {} : { suppressEmbeds: opts.suppressEmbeds }),
});
}
@@ -323,7 +321,6 @@ export async function sendDiscordComponentMessage(
let result: { id: string; channel_id: string };
try {
await opts.onPlatformSendDispatch?.();
result = (await request(
() =>
createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, {
-12
View File
@@ -70,8 +70,6 @@ type DiscordSendOpts = {
allowedMentions?: DiscordAllowedMentions;
/** Persist each concrete platform send before any later chunk can fail. */
onDeliveryResult?: (result: DiscordSendResult) => Promise<void> | void;
/** @internal Refresh durable custody immediately before Discord REST I/O. */
onPlatformSendDispatch?: () => Promise<void>;
};
type DiscordClientRequest = ReturnType<typeof createDiscordClient>["request"];
@@ -94,7 +92,6 @@ async function sendDiscordThreadTextChunks(params: {
suppressEmbeds?: boolean;
allowedMentions?: DiscordAllowedMentions;
onResult?: DiscordSendProgress;
onPlatformSendDispatch?: () => Promise<void>;
}): Promise<void> {
for (const chunk of params.chunks) {
await sendDiscordText({
@@ -109,7 +106,6 @@ async function sendDiscordThreadTextChunks(params: {
allowedMentions: params.allowedMentions,
maxChars: params.maxChars,
onResult: params.onResult,
onPlatformSendDispatch: params.onPlatformSendDispatch,
});
}
}
@@ -242,7 +238,6 @@ export async function sendMessageDiscord(
});
let threadRes: { id: string; message?: { id: string; channel_id: string } };
try {
await opts.onPlatformSendDispatch?.();
threadRes = (await request(
() =>
createThread<{ id: string; message?: { id: string; channel_id: string } }>(
@@ -314,7 +309,6 @@ export async function sendMessageDiscord(
allowedMentions: opts.allowedMentions,
maxChars: textLimit,
onResult: reportThreadResult,
onPlatformSendDispatch: opts.onPlatformSendDispatch,
});
await sendDiscordThreadTextChunks({
rest,
@@ -328,7 +322,6 @@ export async function sendMessageDiscord(
suppressEmbeds,
allowedMentions: opts.allowedMentions,
onResult: reportThreadResult,
onPlatformSendDispatch: opts.onPlatformSendDispatch,
});
} else {
await sendDiscordThreadTextChunks({
@@ -343,7 +336,6 @@ export async function sendMessageDiscord(
suppressEmbeds,
allowedMentions: opts.allowedMentions,
onResult: reportThreadResult,
onPlatformSendDispatch: opts.onPlatformSendDispatch,
});
}
} catch (err) {
@@ -399,7 +391,6 @@ export async function sendMessageDiscord(
allowedMentions: opts.allowedMentions,
maxChars: textLimit,
onResult: reportResult,
onPlatformSendDispatch: opts.onPlatformSendDispatch,
});
} else {
result = await sendDiscordText({
@@ -417,7 +408,6 @@ export async function sendMessageDiscord(
allowedMentions: opts.allowedMentions,
maxChars: textLimit,
onResult: reportResult,
onPlatformSendDispatch: opts.onPlatformSendDispatch,
});
}
} catch (err) {
@@ -457,7 +447,6 @@ export async function sendStickerDiscord(
enforce_nonce: true,
...(flags ? { flags } : {}),
};
await opts.onPlatformSendDispatch?.();
const res = (await request(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"sticker",
@@ -485,7 +474,6 @@ export async function sendPollDiscord(
enforce_nonce: true,
...(flags ? { flags } : {}),
};
await opts.onPlatformSendDispatch?.();
const res = (await request(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"poll",
-6
View File
@@ -325,7 +325,6 @@ type DiscordTextSendParams = {
suppressEmbeds?: boolean;
maxChars?: number;
onResult?: DiscordSendProgress;
onPlatformSendDispatch?: () => Promise<void>;
};
async function sendDiscordText(params: DiscordTextSendParams) {
@@ -344,7 +343,6 @@ async function sendDiscordText(params: DiscordTextSendParams) {
suppressEmbeds,
maxChars,
onResult,
onPlatformSendDispatch,
} = params;
if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends");
@@ -371,7 +369,6 @@ async function sendDiscordText(params: DiscordTextSendParams) {
flags,
replyTo: chunkReplyTo,
});
await onPlatformSendDispatch?.();
const result = (await request(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"text",
@@ -432,7 +429,6 @@ async function sendDiscordMedia(params: DiscordMediaSendParams) {
suppressEmbeds,
maxChars,
onResult,
onPlatformSendDispatch,
} = params;
const media = await loadWebMedia(
mediaUrl,
@@ -476,7 +472,6 @@ async function sendDiscordMedia(params: DiscordMediaSendParams) {
});
let res: { id: string; channel_id: string };
try {
await onPlatformSendDispatch?.();
res = (await request(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"media",
@@ -501,7 +496,6 @@ async function sendDiscordMedia(params: DiscordMediaSendParams) {
allowedMentions,
maxChars,
onResult,
onPlatformSendDispatch,
});
}
await onResult?.(res, "media", reply?.messageId);
-2
View File
@@ -38,7 +38,6 @@ type VoiceMessageOpts = Pick<
| "mediaAccess"
| "mediaLocalRoots"
| "mediaReadFile"
| "onPlatformSendDispatch"
>;
function toDiscordSendResult(
@@ -123,7 +122,6 @@ export async function sendVoiceMessageDiscord(
const metadata = await getVoiceMessageMetadata(oggPath);
const audioBuffer = await fs.readFile(oggPath);
await opts.onPlatformSendDispatch?.();
const result = await sendDiscordVoiceMessage(
rest,
channelId,
-2
View File
@@ -40,7 +40,6 @@ type DiscordWebhookSendOpts = {
username?: string;
avatarUrl?: string;
wait?: boolean;
onPlatformSendDispatch?: () => Promise<void>;
};
function resolveWebhookExecutionUrl(params: {
@@ -155,7 +154,6 @@ export async function sendWebhookMessageDiscord(
try {
const response = await request(
async () => {
await opts.onPlatformSendDispatch?.();
const attemptResponse = await (proxyFetch ?? fetch)(url, {
method: "POST",
headers: {
@@ -208,10 +208,7 @@ export function createTelegramDeliveryController(params: {
) {
return payload;
}
return {
...payload,
replyToId: implicitQuoteReplyTargetId,
};
return { ...payload, replyToId: implicitQuoteReplyTargetId };
};
const usesNativeTelegramQuote = (payload: ReplyPayload): boolean =>
params.replyQuoteText != null ||
@@ -226,8 +223,6 @@ export function createTelegramDeliveryController(params: {
mirrorTranscript?: boolean;
promptContextSequence?: TelegramPromptContextProjectionSequence;
textMode?: "html";
onPlatformSendDispatch?: () => Promise<void>;
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T;
},
) => {
if (params.isDispatchSuperseded()) {
@@ -258,13 +253,10 @@ export function createTelegramDeliveryController(params: {
)
: undefined,
);
const projectedPayload = withTelegramPromptContextSource(
const effectivePayload = withTelegramPromptContextSource(
deliverablePayload,
projectionSequence.source,
);
const effectivePayload = options?.bindPendingFinalDelivery
? options.bindPendingFinalDelivery(projectedPayload)
: projectedPayload;
const silent =
options?.silent ??
(params.telegramCfg.silentErrorReplies === true && payload.isError === true);
@@ -324,7 +316,6 @@ export function createTelegramDeliveryController(params: {
silent,
mediaLoader: params.telegramDeps.loadWebMedia,
promptContextSequence: projectionSequence,
onPlatformSendDispatch: options?.onPlatformSendDispatch,
...(options?.textMode ? { textMode: options.textMode } : {}),
});
if (!result.delivered) {
@@ -441,8 +432,6 @@ export function createTelegramDeliveryController(params: {
payload: ReplyPayload,
text: string,
promptContextSequence: TelegramPromptContextProjectionSequence,
onPlatformSendDispatch?: () => Promise<void>,
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T,
): Promise<LaneDeliveryResult> => {
const afterAcceptedDraft = params.draft.answerLane.stream?.hasConsumedReplyTarget?.() === true;
if (payload.isError === true) {
@@ -452,8 +441,6 @@ export function createTelegramDeliveryController(params: {
afterAcceptedDraft,
durable: true,
promptContextSequence,
onPlatformSendDispatch,
bindPendingFinalDelivery,
});
if (!delivered) {
return { kind: "skipped" };
@@ -467,8 +454,6 @@ export function createTelegramDeliveryController(params: {
afterAcceptedDraft,
durable: true,
promptContextSequence,
onPlatformSendDispatch,
bindPendingFinalDelivery,
});
if (barLine) {
await params.progress.applyCollapseSummary(barLine, postCosmeticSummaryBar);
@@ -487,8 +472,6 @@ export function createTelegramDeliveryController(params: {
answerPayload: ReplyPayload,
text: string,
buttons?: TelegramInlineButtons,
onPlatformSendDispatch?: () => Promise<void>,
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T,
): Promise<LaneDeliveryResult> => {
const transcriptFinal = await resolveCurrentTurnTranscriptFinal();
const finalText = await resolveTranscriptBackedChannelFinalText({
@@ -508,8 +491,6 @@ export function createTelegramDeliveryController(params: {
answerPayload,
finalText,
promptContextSequence,
onPlatformSendDispatch,
bindPendingFinalDelivery,
);
} else {
if (isFollowUp) {
@@ -525,8 +506,6 @@ export function createTelegramDeliveryController(params: {
buttons,
allowStream: !usesNativeTelegramQuote(answerPayload),
promptContextSequence,
onPlatformSendDispatch,
bindPendingFinalDelivery,
});
if (!isFollowUp && result.kind !== "skipped") {
params.progress.markFinalDelivered();
@@ -9,8 +9,8 @@ import {
isFastModeAutoProgressPayload,
isReplyPayloadNonTerminalToolErrorWarning,
resolveSendableOutboundReplyParts,
type ReplyPayload,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotDeps } from "./bot-deps.js";
@@ -105,8 +105,6 @@ export function createTelegramReplyDelivery(params: {
| {
promise: Promise<{ visibleReplySent: boolean }>;
visibleReplySent: boolean;
onPlatformSendDispatch?: () => Promise<void>;
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T;
resolve: (result: { visibleReplySent: boolean }) => void;
reject: (error: unknown) => void;
}
@@ -146,8 +144,6 @@ export function createTelegramReplyDelivery(params: {
buffered.payload,
buffered.text,
resolvePayloadTelegramInlineButtons(buffered.payload),
settlement?.onPlatformSendDispatch,
settlement?.bindPendingFinalDelivery,
);
if (settlement) {
settlement.resolve({
@@ -275,8 +271,6 @@ export function createTelegramReplyDelivery(params: {
bufferedFinalSettlement = {
promise: finalization,
visibleReplySent: blockDelivered,
onPlatformSendDispatch: info.onPlatformSendDispatch,
bindPendingFinalDelivery: info.bindPendingFinalDelivery,
resolve: resolveFinalization,
reject: rejectFinalization,
};
@@ -387,8 +381,6 @@ export function createTelegramReplyDelivery(params: {
effectivePayload,
segment.update.text,
telegramButtons,
info.onPlatformSendDispatch,
info.bindPendingFinalDelivery,
)
: await params.delivery.deliverLaneText({
laneName: segment.lane,
@@ -397,8 +389,6 @@ export function createTelegramReplyDelivery(params: {
infoKind: info.kind,
buttons: telegramButtons,
allowStream: !isDurableProgressCommentary,
onPlatformSendDispatch: info.onPlatformSendDispatch,
bindPendingFinalDelivery: info.bindPendingFinalDelivery,
});
if (
segment.lane === "answer" &&
@@ -450,8 +440,6 @@ export function createTelegramReplyDelivery(params: {
: effectivePayload;
delivered = await params.delivery.sendPayload(payloadWithoutReasoning, {
durable: info.kind === "final",
onPlatformSendDispatch: info.onPlatformSendDispatch,
bindPendingFinalDelivery: info.bindPendingFinalDelivery,
});
}
if (info.kind === "final" && delivered) {
@@ -475,8 +463,6 @@ export function createTelegramReplyDelivery(params: {
}
const delivered = await params.delivery.sendPayload(effectivePayload, {
durable: info.kind === "final",
onPlatformSendDispatch: info.onPlatformSendDispatch,
bindPendingFinalDelivery: info.bindPendingFinalDelivery,
});
if (info.kind === "final" && delivered) {
params.progress.markFinalDelivered();
@@ -1780,7 +1780,7 @@ export const registerTelegramNativeCommands = ({
},
},
delivery: {
deliverWithProviderMessageSending: async (payload, info) => {
deliverWithProviderMessageSending: async (payload) => {
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg: runtimeCfg,
@@ -1805,7 +1805,6 @@ export const registerTelegramNativeCommands = ({
],
...deliveryBaseOptions,
silent: runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true,
onPlatformSendDispatch: info.onPlatformSendDispatch,
});
if (result.delivered) {
deliveryState.delivered = true;
@@ -71,21 +71,16 @@ export function createTelegramNativeCommandTestDeps(
): { dispatchChannelInboundTurn: DispatchChannelInboundTurn } {
return {
dispatchChannelInboundTurn: async (plan) => {
const delivery = plan.delivery;
const dispatchResult = await dispatchReply({
ctx: plan.ctxPayload,
cfg: plan.cfg,
dispatcherOptions: {
...plan.dispatcherOptions,
deliver:
"deliverWithProviderMessageSending" in delivery
? (payload, info) =>
delivery.deliverWithProviderMessageSending(payload, {
...info,
onPlatformSendDispatch: info.onPlatformSendDispatch ?? (async () => undefined),
})
: delivery.deliver,
onError: delivery.onError,
"deliverWithProviderMessageSending" in plan.delivery
? plan.delivery.deliverWithProviderMessageSending
: plan.delivery.deliver,
onError: plan.delivery.onError,
},
replyOptions: plan.replyOptions,
});
+1 -5
View File
@@ -38,11 +38,7 @@ export async function runTelegramChannelInboundEventWithHarness(
cfg: plan.cfg,
dispatcherOptions: {
...plan.dispatcherOptions,
deliver: (payload, info) =>
plan.delivery.deliverWithProviderMessageSending(payload, {
...info,
onPlatformSendDispatch: info.onPlatformSendDispatch ?? (async () => undefined),
}),
deliver: plan.delivery.deliverWithProviderMessageSending,
onError: plan.delivery.onError,
},
toolsAllow: plan.toolsAllow,
@@ -254,7 +254,6 @@ async function deliverTextReply(params: {
progress: DeliveryProgress;
recordMessageId: (messageId: number) => void;
quoteOnlyOnFirstChunk?: boolean;
onPlatformSendDispatch?: () => Promise<void>;
}): Promise<number | undefined> {
let firstDeliveredMessageId: number | undefined;
const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.text));
@@ -279,7 +278,6 @@ async function deliverTextReply(params: {
markDelivered,
sendChunk: async ({ chunk, isFirstChunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
const includeQuoteMetadata = params.quoteOnlyOnFirstChunk !== true || isFirstChunk;
await params.onPlatformSendDispatch?.();
const messageId = await sendTelegramText(
params.bot,
params.chatId,
@@ -353,7 +351,6 @@ async function deliverMediaReply(params: {
progress: DeliveryProgress;
recordMessageId: (messageId: number) => void;
textMode?: "html";
onPlatformSendDispatch?: () => Promise<void>;
}): Promise<{ firstDeliveredMessageId?: number; visibleFallbackText?: string }> {
let firstDeliveredMessageId: number | undefined;
let visibleFallbackText: string | undefined;
@@ -375,7 +372,6 @@ async function deliverMediaReply(params: {
plainCaption?: string;
shouldLog?: (err: unknown) => boolean;
}) => {
await params.onPlatformSendDispatch?.();
const delivery = await sendTelegramCaptionedMediaWithFallback({
operation: options.sender.operation,
requestParams: options.requestParams,
@@ -521,7 +517,6 @@ async function deliverMediaReply(params: {
progress: createVoiceFallbackProgress(),
recordMessageId: params.recordMessageId,
quoteOnlyOnFirstChunk: true,
onPlatformSendDispatch: params.onPlatformSendDispatch,
});
await params.onVoiceRecording?.();
@@ -617,7 +612,6 @@ async function deliverMediaReply(params: {
replyToMode: params.replyToMode,
progress: params.progress,
recordMessageId: params.recordMessageId,
onPlatformSendDispatch: params.onPlatformSendDispatch,
});
if (followUpMessageId === undefined) {
visibleFallbackText = firstDeliveredCaption ?? "";
@@ -789,8 +783,6 @@ export async function deliverReplies(params: {
promptContextSequence?: TelegramPromptContextProjectionSequence;
/** Text is already prepared Telegram HTML and must not be parsed as Markdown again. */
textMode?: "html";
/** @internal Claim delivery custody immediately before Telegram Bot API I/O. */
onPlatformSendDispatch?: () => Promise<void>;
}): Promise<{
delivered: boolean;
}> {
@@ -938,7 +930,6 @@ export async function deliverReplies(params: {
);
let firstDeliveredMessageId: number | undefined;
if (reactionEmoji && typeof replyToId === "number") {
await params.onPlatformSendDispatch?.();
const reactionResult = await reactMessageTelegram(params.chatId, replyToId, reactionEmoji, {
cfg: params.cfg ?? { channels: { telegram: { botToken: params.token } } },
token: params.token,
@@ -975,7 +966,6 @@ export async function deliverReplies(params: {
replyToMode: params.replyToMode,
progress,
recordMessageId,
onPlatformSendDispatch: params.onPlatformSendDispatch,
});
} else if (mediaList.length > 0) {
const mediaDelivery = await deliverMediaReply({
@@ -1003,7 +993,6 @@ export async function deliverReplies(params: {
replyToMode: params.replyToMode,
progress,
recordMessageId,
onPlatformSendDispatch: params.onPlatformSendDispatch,
...(params.textMode ? { textMode: params.textMode } : {}),
});
firstDeliveredMessageId = mediaDelivery.firstDeliveredMessageId;
+3 -14
View File
@@ -61,7 +61,7 @@ const MAX_PREVIEW_FLOOD_SUSPEND_MS = 60_000;
const MIN_PREVIEW_DWELL_MS = 4_000;
export type TelegramDraftStream = {
update: (text: string, options?: { onPlatformSendDispatch?: () => Promise<void> }) => void;
update: (text: string) => void;
updateLazy: (resolveText: () => string | undefined) => void;
updatePreview: (preview: TelegramDraftPreview) => void;
flush: () => Promise<void>;
@@ -323,7 +323,6 @@ export function createTelegramDraftStream(params: {
let lastDeliveredText = "";
let lastRequestedText = "";
let lastRequestedPreview: TelegramDraftPreview | undefined;
let pendingPlatformSendDispatch: (() => Promise<void>) | undefined;
let generation = 0;
let finalPagePlan: { pages: PlannedTelegramDraftPage[]; nextPageIndex: number } | undefined;
// Generations whose in-flight FIRST send was superseded by a reposition
@@ -451,10 +450,6 @@ export function createTelegramDraftStream(params: {
page: PlannedTelegramDraftPage,
sendGeneration: number,
): Promise<boolean> => {
if (pendingPlatformSendDispatch) {
await pendingPlatformSendDispatch();
pendingPlatformSendDispatch = undefined;
}
const targetMessageId = streamMessageId;
if (typeof targetMessageId === "number") {
streamVisibleSinceMs ??= Date.now();
@@ -776,17 +771,12 @@ export function createTelegramDraftStream(params: {
throwTerminalDeliveryError();
};
const requestDraftUpdate = (
text: string,
preview?: TelegramDraftPreview,
onPlatformSendDispatch?: () => Promise<void>,
) => {
const requestDraftUpdate = (text: string, preview?: TelegramDraftPreview) => {
if (streamState.stopped || streamState.final) {
return;
}
lastRequestedPreview = preview;
lastRequestedText = text;
pendingPlatformSendDispatch = onPlatformSendDispatch;
updateDraft(text);
};
@@ -859,7 +849,6 @@ export function createTelegramDraftStream(params: {
streamState.final = true;
observeCurrentProviderMessage();
await drainProviderMessageObservations();
pendingPlatformSendDispatch = undefined;
};
const remainingFinalContent = (): TelegramDraftMessageSnapshot | undefined => {
@@ -1060,7 +1049,7 @@ export function createTelegramDraftStream(params: {
params.log?.(`telegram stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
update: (text, options) => requestDraftUpdate(text, undefined, options?.onPlatformSendDispatch),
update: requestDraftUpdate,
updateLazy: requestLazyDraftUpdate,
updatePreview,
flush,
@@ -11,8 +11,8 @@ import {
buildTtsSupplementMediaPayload,
getReplyPayloadTtsSupplement,
resolveSendableOutboundReplyParts,
type ReplyPayload,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js";
import type { TelegramDraftStream } from "./draft-stream.js";
import type { TelegramPromptContextProjectionSequence } from "./prompt-context-projection.js";
@@ -55,8 +55,6 @@ type CreateLaneTextDelivererParams = {
durable?: boolean;
promptContextSequence?: TelegramPromptContextProjectionSequence;
textMode?: "html";
onPlatformSendDispatch?: () => Promise<void>;
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T;
},
) => Promise<boolean>;
flushDraftLane: (lane: DraftLaneState) => Promise<void>;
@@ -88,8 +86,6 @@ type DeliverLaneTextParams = {
durable?: boolean;
allowStream?: boolean;
promptContextSequence?: TelegramPromptContextProjectionSequence;
onPlatformSendDispatch?: () => Promise<void>;
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T;
};
function result(
@@ -280,7 +276,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
promptContextSequence: TelegramPromptContextProjectionSequence,
followedByDurablePayload = false,
allowErrorPayload = false,
onPlatformSendDispatch?: () => Promise<void>,
): Promise<LaneDeliveryResult | undefined> => {
const stream = lane.stream;
if (!stream || text.length === 0 || (payload.isError && !allowErrorPayload)) {
@@ -306,15 +301,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
lane.lastPartialText = previewText;
lane.hasStreamedMessage = true;
lane.finalized = false;
const previewAlreadyVisible = stream.lastDeliveredText?.() === previewText;
if (!previewAlreadyVisible) {
if (finalizePreview && onPlatformSendDispatch) {
stream.update(previewText, { onPlatformSendDispatch });
} else {
stream.update(previewText);
}
} else if (finalizePreview) {
await onPlatformSendDispatch?.();
if (stream.lastDeliveredText?.() !== previewText) {
stream.update(previewText);
}
if (finalizePreview) {
await params.stopDraftLane(lane);
@@ -353,7 +341,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
let buttonsAttached = false;
if (buttons && activeSnapshot) {
try {
await onPlatformSendDispatch?.();
await params.editStreamMessage({
laneName,
messageId,
@@ -396,8 +383,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
durable: requestedDurable,
allowStream = true,
promptContextSequence: suppliedPromptContextSequence,
onPlatformSendDispatch,
bindPendingFinalDelivery,
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
const lane = params.lanes[laneName];
const promptContextSequence =
@@ -440,7 +425,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
promptContextSequence,
false,
streamedErrorDraftText !== undefined,
onPlatformSendDispatch,
)
: undefined;
if (streamed) {
@@ -465,8 +449,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
buttons,
promptContextSequence,
true,
false,
onPlatformSendDispatch,
);
if (finalizedPreview) {
const stripButtons =
@@ -483,8 +465,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
afterAcceptedDraft: true,
durable,
promptContextSequence,
onPlatformSendDispatch,
bindPendingFinalDelivery,
},
);
return finalizedPreview;
@@ -511,8 +491,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
afterAcceptedDraft,
durable,
promptContextSequence,
onPlatformSendDispatch,
bindPendingFinalDelivery,
...(retainedFinalContent?.sourceTextMode === "html" ? { textMode: "html" } : {}),
},
);
@@ -184,35 +184,6 @@ describe("createLaneTextDeliverer", () => {
expect(harness.lanes.answer.finalized).toBe(true);
});
it("claims an equal visible preview before finalization can crash", async () => {
const events: string[] = [];
const answer = createTestDraftStream({ messageId: 999 });
answer.lastDeliveredText.mockReturnValue(HELLO_FINAL);
const harness = createHarness({ answerStream: answer });
const finalizationCrash = new Error("injected finalization crash");
harness.stopDraftLane.mockImplementationOnce(async () => {
events.push("finalize");
throw finalizationCrash;
});
const onPlatformSendDispatch = vi.fn(async () => {
events.push("custody");
});
await expect(
harness.deliverLaneText({
laneName: "answer",
text: HELLO_FINAL,
payload: { text: HELLO_FINAL },
infoKind: "final",
onPlatformSendDispatch,
}),
).rejects.toBe(finalizationCrash);
expect(events).toEqual(["custody", "finalize"]);
expect(answer.update).not.toHaveBeenCalled();
expect(onPlatformSendDispatch).toHaveBeenCalledOnce();
});
it("streams block and final text through the same lane", async () => {
const harness = createHarness({ answerMessageId: 999 });
@@ -77,7 +77,6 @@ async function resolveTelegramSendContext(params: {
onDeliveryResult?: Parameters<
NonNullable<ChannelOutboundAdapter["sendText"]>
>[0]["onDeliveryResult"];
onPlatformSendDispatch?: () => Promise<void>;
resolveSend: ResolveTelegramSendFn;
}): Promise<{
send: TelegramSendFn;
@@ -94,7 +93,6 @@ async function resolveTelegramSendContext(params: {
silent?: boolean;
gatewayClientScopes?: readonly string[];
onDeliveryResult?: TelegramSendOpts["onDeliveryResult"];
onPlatformSendDispatch?: TelegramSendOpts["onPlatformSendDispatch"];
};
}> {
const send = await params.resolveSend(params.deps);
@@ -115,7 +113,6 @@ async function resolveTelegramSendContext(params: {
await params.onDeliveryResult?.(attachChannelToResult("telegram", result));
}
: undefined,
onPlatformSendDispatch: params.onPlatformSendDispatch,
...(params.formatting?.parseMode === "HTML" ? { textMode: "html" as const } : {}),
tableMode: params.formatting?.tableMode,
},
@@ -386,7 +383,6 @@ export async function sendTelegramPayloadMessages(params: {
if (typeof replyToMessageId !== "number") {
throw new Error("Telegram reaction requires a reply target");
}
await params.baseOpts.onPlatformSendDispatch?.();
const reactionResult = await params.react(params.to, replyToMessageId, reactionEmoji, {
cfg: params.baseOpts.cfg,
accountId: params.baseOpts.accountId,
@@ -601,7 +597,6 @@ export function createTelegramOutboundAdapter(
silent,
isAnonymous,
gatewayClientScopes,
onPlatformSendDispatch,
}) => {
const outboundTo = normalizeTelegramOutboundTarget(to);
const { sendPollTelegram } = await loadSendModule();
@@ -612,7 +607,6 @@ export function createTelegramOutboundAdapter(
silent: silent ?? undefined,
isAnonymous: isAnonymous ?? undefined,
gatewayClientScopes,
onPlatformSendDispatch,
});
},
};
+3 -5
View File
@@ -72,9 +72,8 @@ async function sendLocationTelegramWithContext(
const delivery = await withTelegramNativeQuoteFallback({
label,
requestParams: commonParams,
request: async (effectiveParams, retryLabel) => {
await opts.onPlatformSendDispatch?.();
return await prepared.request(
request: (effectiveParams, retryLabel) =>
prepared.request(
() =>
hasName
? api.sendVenue(
@@ -92,8 +91,7 @@ async function sendLocationTelegramWithContext(
: {}),
} as TelegramSendLocationParams),
retryLabel,
);
},
),
});
const result = delivery.result;
const acceptedParams = toAcceptedThreadScopedParams(delivery.acceptedParams);
+3 -5
View File
@@ -149,16 +149,14 @@ export function createTelegramTextSender(config: {
withTelegramNativeQuoteFallback({
label,
requestParams,
request: async (effectiveParams, retryLabel) => {
await opts.onPlatformSendDispatch?.();
return await requestWithChatNotFound(
request: (effectiveParams, retryLabel) =>
requestWithChatNotFound(
() =>
Object.keys(effectiveParams).length > 0
? api.sendMessage(chatId, messageText, effectiveParams)
: api.sendMessage(chatId, messageText),
retryLabel,
);
},
),
});
const requestPlain = (label: string) =>
requestSendMessage(label, chunk.plainText, plainParams ?? {});
@@ -49,8 +49,6 @@ export type TelegramSendOpts = {
forceDocument?: boolean;
/** Persist each concrete platform send before any later chunk can fail. */
onDeliveryResult?: (result: TelegramSendResult) => Promise<void> | void;
/** @internal Refresh durable custody immediately before Telegram Bot API I/O. */
onPlatformSendDispatch?: () => Promise<void>;
};
export type TelegramApiCallOpts = Pick<
@@ -78,10 +76,5 @@ export type TelegramSendResult = {
export type TelegramLocationSendOpts = TelegramThreadedSendOpts &
Pick<
TelegramSendOpts,
| "buttons"
| "quoteText"
| "promptContextProjectionPlan"
| "silent"
| "onDeliveryResult"
| "onPlatformSendDispatch"
"buttons" | "quoteText" | "promptContextProjectionPlan" | "silent" | "onDeliveryResult"
>;
+3 -5
View File
@@ -288,14 +288,12 @@ async function sendMessageTelegramWithContext(
withTelegramNativeQuoteFallback({
label,
requestParams,
request: async (effectiveParams, effectiveLabel) => {
await opts.onPlatformSendDispatch?.();
return await requestWithChatNotFound(
request: (effectiveParams, effectiveLabel) =>
requestWithChatNotFound(
() => sender(effectiveParams),
effectiveLabel,
shouldLog ? { shouldLog } : undefined,
);
},
),
}),
});
};
+1 -2
View File
@@ -102,7 +102,7 @@ async function sendStickerTelegramWithContext(
}
type TelegramPollOpts = TelegramThreadedSendOpts &
Pick<TelegramSendOpts, "onPlatformSendDispatch" | "silent"> & {
Pick<TelegramSendOpts, "silent"> & {
/** Whether votes are anonymous. Defaults to true (Telegram default). */
isAnonymous?: boolean;
};
@@ -163,7 +163,6 @@ async function sendPollTelegramWithContext(
...(opts.silent === true ? { disable_notification: true } : {}),
};
await opts.onPlatformSendDispatch?.();
const result = await prepared.request(
() =>
api.sendPoll(prepared.chatId, normalizedPoll.question, normalizedPoll.options, pollParams),
@@ -828,7 +828,7 @@ describe("agentCommand compaction transcript rotation", () => {
},
);
it("compacts after persisting transport ownership for finals that text cannot replay", async () => {
it("skips post-turn compaction before delivering sendable finals that pending text cannot replay", async () => {
const sessionId = "unrecoverable-media-before-compaction";
const sessionKey = `agent:main:explicit:${sessionId}`;
const payloads = [{ mediaUrl: "/tmp/reply.ogg", audioAsVoice: true }];
@@ -845,7 +845,7 @@ describe("agentCommand compaction transcript rotation", () => {
deliver: true,
});
expect(state.runCliTurnCompactionLifecycleMock).toHaveBeenCalledOnce();
expect(state.runCliTurnCompactionLifecycleMock).not.toHaveBeenCalled();
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledOnce();
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledWith(
expect.objectContaining({ payloads }),
+4 -47
View File
@@ -1,6 +1,5 @@
import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js";
import type { CliDeps } from "../../cli/deps.types.js";
import { buildRestartRecoveryClaimCleanupPatch } from "../../config/sessions/restart-recovery-state.js";
import type { RestartRecoveryTerminalDeliveryEvidenceResult } from "../../config/sessions/restart-recovery-types.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
@@ -70,7 +69,6 @@ export async function finalizeEmbeddedAgentCommand(params: {
cwd,
agentDir,
outboundSession,
runId,
agentCfg,
} = params.prepared;
const {
@@ -375,8 +373,7 @@ export async function finalizeEmbeddedAgentCommand(params: {
!params.suppressVisibleSessionEffects &&
!sessionReboundDuringRun
) {
const entry =
(await resolveFreshSessionEntryForDelivery?.()) ?? sessionStore[sessionKey] ?? sessionEntry;
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) {
throw new Error("Cannot clear pending delivery without a session entry");
}
@@ -385,55 +382,15 @@ export async function finalizeEmbeddedAgentCommand(params: {
params.opts.deliver === true &&
!pendingFinalDeliveryMarker.hasSendableFinalPayload &&
entry.pendingFinalDelivery?.kind === "transport-only";
const clearOwnedPendingFinal =
deliveryResult?.deliverySucceeded === true &&
pendingFinalDeliveryMarker.pendingFinalDeliveryIntentId !== undefined;
// Preserve the exact local claim through sibling session writes so a delivered
// source is tombstoned before admission release can erase its ownership fields.
const recoveryClaimEntry =
entry.restartRecoveryDeliveryRunId === runId
? entry
: sessionEntry?.restartRecoveryDeliveryRunId === runId
? sessionEntry
: params.sessionEntry?.restartRecoveryDeliveryRunId === runId
? params.sessionEntry
: undefined;
if (clearOwnedPendingFinal || clearStaleTransportOnly || recoveryClaimEntry) {
const now = Date.now();
if (deliveryResult?.deliverySucceeded === true || clearStaleTransportOnly) {
sessionEntry = await persistSessionEntry({
sessionStore,
sessionKey,
storePath,
initialEntry: entry,
entry: {
...(clearOwnedPendingFinal || clearStaleTransportOnly
? clearPendingFinalDelivery(entry, now)
: { ...entry, updatedAt: now }),
...(recoveryClaimEntry
? buildRestartRecoveryClaimCleanupPatch({
entry: {
...recoveryClaimEntry,
restartRecoveryTerminalDeliveryEvidence:
entry.restartRecoveryTerminalDeliveryEvidence,
restartRecoveryTerminalRunIds: entry.restartRecoveryTerminalRunIds,
},
recordTerminalSource: true,
terminalDeliveryEvidence: buildRestartRecoveryTerminalDeliveryEvidence(
deliveryResult ?? result,
),
terminalRunId: runId,
})
: {}),
},
entry: clearPendingFinalDelivery(entry, Date.now()),
shouldPersist: (current) =>
shouldPersistCurrentRunSessionCleanup(current, runOwnedSessionId) &&
(!recoveryClaimEntry ||
current?.restartRecoveryDeliveryRunId === undefined ||
current.restartRecoveryDeliveryRunId === runId) &&
(!clearOwnedPendingFinal ||
current?.pendingFinalDelivery?.intentId ===
pendingFinalDeliveryMarker.pendingFinalDeliveryIntentId) &&
(!clearStaleTransportOnly || current?.pendingFinalDelivery?.kind === "transport-only"),
shouldPersistCurrentRunSessionCleanup(current, runOwnedSessionId),
});
}
}
+5 -2
View File
@@ -693,7 +693,8 @@ describe("runEmbeddedAgent", () => {
};
resolveModelAsyncMock.mockImplementation(async (provider: string, modelId: string) => {
if (provider === "openai" && modelId === "gpt-5.5") {
return createResolvedEmbeddedRunnerModel(provider, modelId);
const resolved = createResolvedEmbeddedRunnerModel(provider, modelId);
return { ...resolved, model: { ...resolved.model, contextWindow: 272_000 } };
}
return {
error: `Unknown model: ${provider}/${modelId}`,
@@ -709,10 +710,11 @@ describe("runEmbeddedAgent", () => {
lastAssistant: buildEmbeddedRunnerAssistant({
content: [{ type: "text", text: "ok" }],
}),
contextTokens: 1_050_000,
}),
);
await runEmbeddedAgent({
const result = await runEmbeddedAgent({
sessionId: "codex-runtime-model",
sessionFile,
workspaceDir,
@@ -740,6 +742,7 @@ describe("runEmbeddedAgent", () => {
expect(
(firstRunEmbeddedAttemptParams() as { model?: { provider?: string } }).model?.provider,
).toBe("openai");
expect(result.meta.agentMeta?.contextTokens).toBe(1_050_000);
});
it("resolves a transport-owned Codex model from the bundled static catalog in one resolver pass", async () => {
@@ -100,7 +100,7 @@ export function prepareEmbeddedRunTerminal(input: {
sessionFile: input.sessionFileUsed,
provider: reportedModelRef.provider,
model: reportedModelRef.model,
...input.outerContextTokenMeta,
contextTokens: attempt.contextTokens ?? input.outerContextTokenMeta.contextTokens,
agentHarnessId: attempt.agentHarnessId,
usage: usageMeta.usage,
lastCallUsage: usageMeta.lastCallUsage,
@@ -300,6 +300,8 @@ export type EmbeddedRunAttemptResult = {
hasToolMediaBlockReply?: boolean;
successfulCronAdds?: number;
cloudCodeAssistFormatError: boolean;
/** Effective context window reported by the harness during this attempt. */
contextTokens?: number;
attemptUsage?: NormalizedUsage;
promptCache?: ContextEnginePromptCacheInfo;
contextBudgetStatus?: SessionContextBudgetStatus;
@@ -14,7 +14,6 @@ export function scheduleMainSessionRecoveryPendingTarget(
getConfig: getRuntimeConfig,
getGatewayRuntime: getGatewayRecoveryRuntime,
sessionKey: target.sessionKey,
stateDir: target.stateDir,
storePath: target.storePath,
}),
() => {}, // Startup recovery remains the fallback if this optional module cannot load.
@@ -32,7 +32,6 @@ type MainSessionRecoveryStoreResult = {
export type MainSessionRecoveryPendingTarget = MainSessionRecoveryStoreTarget & {
sessionId: string;
stateDir?: string;
};
function matchesReservation(entry: SessionEntry, reservation: MainSessionRecoveryReservation) {
@@ -298,7 +298,6 @@ export async function markSessionCompletedAfterRecoveryCheckpoint(params: {
agentId: string;
entry: SessionEntry;
messages: readonly unknown[];
pendingFinalDeliveryIntentId?: string;
reason: "delivered-terminal" | "delivered-terminal-receipt" | "handled-silent";
storePath: string;
sessionKey: string;
@@ -322,7 +321,6 @@ export async function markSessionCompletedAfterRecoveryCheckpoint(params: {
pendingFinalDelivery: undefined,
restartRecoveryForceSafeTools: undefined,
restartRecoveryRuns: undefined,
...buildMainSessionRecoveryClearPatch(params.entry),
runtimeMs:
typeof params.entry.startedAt === "number"
? Math.max(0, endedAt - params.entry.startedAt)
@@ -473,8 +471,6 @@ export async function markSessionCompletedAfterRecoveryCheckpoint(params: {
if (
!entry ||
entry.sessionId !== params.entry.sessionId ||
(params.pendingFinalDeliveryIntentId !== undefined &&
entry.pendingFinalDelivery?.intentId !== params.pendingFinalDeliveryIntentId) ||
entry.status !== "running" ||
entry.abortedLastRun !== true ||
normalizeOptionalString(entry.restartRecoveryDeliveryRunId) !== expectedRecoveryRunId ||
@@ -86,7 +86,6 @@ export async function failUnresumableMainSession(params: {
gatewayRuntime: GatewayRecoveryRuntime;
observation: MainSessionRecoveryObservation;
reason: string;
noticeText?: string;
sessionKey: string;
storePath: string;
}): Promise<"failed" | "skipped"> {
@@ -106,7 +105,6 @@ export async function failUnresumableMainSession(params: {
entry: params.entry,
sessionKey: params.sessionKey,
storePath: params.storePath,
...(params.noticeText ? { text: params.noticeText } : {}),
})) !== "written"
) {
// Keep ownership for another recovery attempt until its terminal notice is durable.
@@ -128,7 +126,7 @@ export async function failUnresumableMainSession(params: {
gatewayRuntime: params.gatewayRuntime,
reason: params.reason,
sessionKey: params.sessionKey,
text: params.noticeText ?? UNRESUMABLE_SESSION_NOTICE,
text: UNRESUMABLE_SESSION_NOTICE,
});
}
return "failed";
@@ -88,7 +88,6 @@ export async function recoverRestartAbortedMainSessions(params: {
cfg: params.cfg,
onExhaustedTarget: params.onExhaustedTarget,
storePath,
stateDir: params.stateDir,
resumedSessionKeys,
activeSessionIds: params.activeSessionIds,
activeSessionKeys: params.activeSessionKeys,
@@ -117,7 +116,6 @@ export async function retryRestartAbortedMainSessionRecovery(params: {
expectedRecoverySourceRunId?: string;
expectedSessionId: string;
sessionKey: string;
stateDir?: string;
storePath: string;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<RecoveryCounts> {
@@ -149,7 +147,6 @@ async function recoverExpectedRestartRecovery(params: {
sessionKey: string;
shouldContinue?: () => boolean;
storePath: string;
stateDir?: string;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<RecoveryCounts> {
const loadExpected = () =>
@@ -189,7 +186,6 @@ async function recoverExpectedRestartRecovery(params: {
cfg: params.cfg,
observationOnly: params.observationOnly,
storePath: params.storePath,
stateDir: params.stateDir,
resumedSessionKeys: new Set<string>(),
expectedClaim: params.expectedClaim,
expectedTarget: params.expectedTarget,
@@ -212,7 +208,6 @@ export function scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease(param
maxRetries?: number;
expectedSessionId: string;
sessionKey: string;
stateDir?: string;
storePath: string;
}): void {
const recover = () =>
@@ -225,7 +220,6 @@ export function scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease(param
cfg: params.getConfig(),
expectedSessionId: params.expectedSessionId,
sessionKey: params.sessionKey,
stateDir: params.stateDir,
storePath: params.storePath,
gatewayRuntime,
});
@@ -333,7 +327,6 @@ export function scheduleRestartAbortedMainSessionRecovery(params: {
sessionKey: target.sessionKey,
shouldContinue,
storePath: target.storePath,
stateDir: params.stateDir,
gatewayRuntime: params.gatewayRuntime,
}),
),
@@ -14,7 +14,6 @@ import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.
import { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
import { resolveGatewaySessionStoreTarget } from "../gateway/session-utils.js";
import { getAgentEventLifecycleGeneration } from "../infra/agent-events.js";
import { findDeliveryIntentOwner } from "../infra/outbound/delivery-queue-storage.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { resolveDefaultAgentId } from "./agent-scope-config.js";
import {
@@ -54,32 +53,6 @@ import {
normalizeStringSet,
} from "./main-session-restart-recovery-shared.js";
function pendingFinalRecoveryAction(
pending: NonNullable<SessionEntry["pendingFinalDelivery"]>,
stateDir?: string,
): "complete" | "defer" | "fail" | "retry" {
const deliveries = pending.deliveries;
if (!deliveries?.length) {
return "fail";
}
if (deliveries.every(({ state }) => state === "delivered" || state === "suppressed")) {
return "complete";
}
const owners = deliveries.map(({ id }) => findDeliveryIntentOwner(id, stateDir));
if (owners.some((owner) => owner?.status === "pending")) {
return "defer";
}
for (const [index, delivery] of deliveries.entries()) {
const owner = owners[index];
if (owner || delivery.state === "delivered" || delivery.state === "unknown") {
return "fail";
}
}
return pending.kind === "replayable" && deliveries.every(({ state }) => state === "prepared")
? "retry"
: "fail";
}
export function loadExpectedRestartRecoveryTarget(params: {
expected: ExpectedRestartRecoveryTarget;
storePath: string;
@@ -126,7 +99,6 @@ export async function recoverStore(params: {
observationOnly?: boolean;
onExhaustedTarget?: (target: ExhaustedRestartRecoveryTarget) => void;
storePath: string;
stateDir?: string;
resumedSessionKeys: Set<string>;
expectedClaim?: ExpectedRestartRecoveryClaim;
expectedTarget?: ExpectedRestartRecoveryTarget;
@@ -321,7 +293,7 @@ export async function recoverStore(params: {
}
}
};
const failCurrent = async (reason: string, noticeText?: string) => {
const failCurrent = async (reason: string) => {
if (stopped()) {
return false;
}
@@ -331,7 +303,6 @@ export async function recoverStore(params: {
gatewayRuntime: params.gatewayRuntime,
observation: recoveryView.observation,
reason,
...(noticeText ? { noticeText } : {}),
sessionKey,
storePath: params.storePath,
});
@@ -392,44 +363,6 @@ export async function recoverStore(params: {
);
};
const pendingAction = entry.pendingFinalDelivery
? pendingFinalRecoveryAction(entry.pendingFinalDelivery, params.stateDir)
: undefined;
if (pendingAction === "defer") {
result.failed++;
continue;
}
if (pendingAction === "complete") {
const completion = await markSessionCompletedAfterRecoveryCheckpoint({
agentId,
entry,
messages: [],
pendingFinalDeliveryIntentId: entry.pendingFinalDelivery?.intentId,
reason: "delivered-terminal-receipt",
sessionKey,
storePath: params.storePath,
});
if (completion.outcome === "completed") {
params.resumedSessionKeys.add(resumeDedupeKey);
result.recovered++;
} else {
result.skipped++;
}
continue;
}
if (pendingAction === "fail") {
if (
!(await failCurrent(
"pending final delivery outcome is unknown",
"My previous response was interrupted during delivery. " +
"Please ask for any missing remainder; I won't rerun your previous request automatically.",
))
) {
return result;
}
continue;
}
if (
entry.pendingFinalDelivery?.kind === "replayable" &&
entry.restartRecoveryForceSafeTools === true
+50 -278
View File
@@ -26,9 +26,6 @@ import {
rotateAgentEventLifecycleGeneration,
} from "../infra/agent-events.js";
import { registerAgentRunContext } from "../infra/agent-run-registry.js";
import { moveDeliveryQueueEntryToFailed } from "../infra/delivery-queue-sqlite.js";
import { OUTBOUND_DELIVERY_QUEUE_NAME } from "../infra/outbound/delivery-queue-media-staging.js";
import { ackDelivery, enqueueDeliveryOnce } from "../infra/outbound/delivery-queue-storage.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
@@ -265,8 +262,6 @@ function makePendingFinalDelivery(
kind: "replayable",
text,
createdAt: Date.now(),
intentId: "intent-prepared-default",
deliveries: [{ id: "delivery-prepared-default", state: "prepared" }],
...overrides,
};
}
@@ -1830,290 +1825,65 @@ describe("main-session-restart-recovery", () => {
expect(store["agent:main:main"]?.abortedLastRun).toBe(true);
});
it.each([
["missing", undefined],
["empty", []],
] as const)(
"fails closed when pending final delivery identities are %s",
async (_, deliveries) => {
const sessionsDir = await makeSessionsDir();
const pendingPayload = "The final answer is 42.";
await writeMainSession({
sessionsDir,
restartRecoveryForceSafeTools: true,
pendingFinalDelivery: {
kind: "replayable",
text: pendingPayload,
createdAt: Date.now() - 5_000,
...(deliveries ? { deliveries: [...deliveries] } : {}),
context: {
channel: "discord",
to: "discord:dm:final",
accountId: "main",
},
},
restartRecoveryBeforeAgentReplyState: "handled-reply",
restartRecoveryDeliveryRunId: "discord-message-1",
restartRecoveryDeliverySourceRunId: "discord-message-1",
restartRecoverySourceIngress: "channel",
restartRecoveryDeliveryContext: {
channel: "discord",
to: "discord:dm:stale",
accountId: "old",
},
});
await writeTranscript(sessionsDir, "main-session", [
{ role: "user", content: "calculate the answer" },
{ role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "calc" }] },
{ role: "toolResult", content: "42" },
]);
await expectRecovery({ recovered: 0, failed: 1, skipped: 0 }, {});
expect(runtimePluginMocks.findRestartRecoveryUnsafeReplyHook).not.toHaveBeenCalled();
expect(callGateway).not.toHaveBeenCalled();
expect(sendRecoveryNotice).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringContaining("ask for any missing remainder") }),
);
},
);
it("retries a prepared pending final only when no queue owner exists", async () => {
it("resumes marked sessions with a durable pending final delivery payload (Phase 2)", async () => {
const sessionsDir = await makeSessionsDir();
const pendingPayload = "The final answer is 42.";
await writeMainSession({
sessionsDir,
pendingFinalDelivery: makePendingFinalDelivery("The prepared final answer.", {
intentId: "intent-prepared",
deliveries: [{ id: "delivery-prepared", state: "prepared" }],
}),
restartRecoveryForceSafeTools: true,
pendingFinalDelivery: {
kind: "replayable",
text: pendingPayload,
createdAt: Date.now() - 5_000,
context: {
channel: "discord",
to: "discord:dm:final",
accountId: "main",
},
},
restartRecoveryBeforeAgentReplyState: "handled-reply",
restartRecoveryDeliveryRunId: "discord-message-1",
restartRecoveryDeliverySourceRunId: "discord-message-1",
restartRecoverySourceIngress: "channel",
restartRecoveryDeliveryContext: {
channel: "discord",
to: "discord:dm:stale",
accountId: "old",
},
});
await writeTranscript(sessionsDir, "main-session", [
{ role: "user", content: "finish the answer" },
{ role: "user", content: "calculate the answer" },
{ role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "calc" }] },
{ role: "toolResult", content: "42" },
]);
await expectRecovery({ recovered: 1, failed: 0, skipped: 0 });
await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }, {});
expect(runtimePluginMocks.findRestartRecoveryUnsafeReplyHook).toHaveBeenCalledWith({
trigger: "user",
});
expect(callGateway).toHaveBeenCalledOnce();
expect(gatewayParams().message).toContain("The prepared final answer.");
});
it("quietly completes a pending final whose deliveries are terminal", async () => {
const sessionsDir = await makeSessionsDir();
const storePath = path.join(sessionsDir, "sessions.json");
await writeMainSession({
sessionsDir,
pendingFinalDelivery: makePendingFinalDelivery("Already delivered.", {
intentId: "intent-delivered",
deliveries: [
{ id: "delivery-delivered", state: "delivered" },
{ id: "delivery-suppressed", state: "suppressed" },
],
}),
expect(gatewayParams()).toMatchObject({
deliver: true,
bestEffortDeliver: true,
channel: "discord",
to: "discord:dm:final",
accountId: "main",
forceRestartSafeTools: true,
});
expect(gatewayParams().message).toContain(pendingPayload);
await expectRecovery({ recovered: 1, failed: 0, skipped: 0 });
expect(callGateway).not.toHaveBeenCalled();
expect(sendRecoveryNotice).not.toHaveBeenCalled();
expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({
status: "done",
abortedLastRun: false,
const beforeStoreRead = Date.now();
const store = readStore(path.join(sessionsDir, "sessions.json"));
const entry = store["agent:main:main"];
expect(entry?.abortedLastRun).toBe(false);
expect(entry?.pendingFinalDelivery).toMatchObject({
kind: "replayable",
text: pendingPayload,
});
expect(
loadSessionEntry({ sessionKey: "agent:main:main", storePath })?.pendingFinalDelivery,
).toBeUndefined();
expect(entry?.restartRecoveryForceSafeTools).toBe(true);
expect(entry?.pendingFinalDelivery?.createdAt).toBeLessThanOrEqual(beforeStoreRead);
});
it.each([
[
{ id: "delivery-already-delivered", state: "delivered" as const },
{ id: "delivery-still-pending", state: "queued" as const },
],
[
{ id: "delivery-still-pending", state: "queued" as const },
{ id: "delivery-already-delivered", state: "delivered" as const },
],
])("defers mixed deliveries while any exact queue owner is pending", async (...deliveries) => {
try {
await enqueueDeliveryOnce(
{
channel: "discord",
to: "discord:dm:123",
payloads: [{ text: "Pending sibling." }],
queuePolicy: "required",
completionRetention: "permanent",
},
"delivery-still-pending",
tmpDir,
);
const sessionsDir = await makeSessionsDir();
await writeMainSession({
sessionsDir,
pendingFinalDelivery: makePendingFinalDelivery("Partially delivered answer.", {
context: discordDeliveryContext,
intentId: "intent-mixed-pending",
deliveries,
}),
});
await expectRecovery({ recovered: 0, failed: 1, skipped: 0 });
expect(callGateway).not.toHaveBeenCalled();
expect(sendRecoveryNotice).not.toHaveBeenCalled();
} finally {
closeOpenClawStateDatabaseForTest();
}
});
it("completes terminal deliveries despite a residual pending queue row", async () => {
try {
await enqueueDeliveryOnce(
{
channel: "discord",
to: "discord:dm:123",
payloads: [{ text: "Already delivered." }],
queuePolicy: "required",
completionRetention: "permanent",
},
"delivery-terminal-with-row",
tmpDir,
);
const sessionsDir = await makeSessionsDir();
const storePath = path.join(sessionsDir, "sessions.json");
await writeMainSession({
sessionsDir,
pendingFinalDelivery: makePendingFinalDelivery("Already delivered.", {
intentId: "intent-terminal-with-row",
deliveries: [{ id: "delivery-terminal-with-row", state: "delivered" }],
}),
});
await expectRecovery({ recovered: 1, failed: 0, skipped: 0 });
expect(callGateway).not.toHaveBeenCalled();
expect(sendRecoveryNotice).not.toHaveBeenCalled();
expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })?.status).toBe("done");
} finally {
closeOpenClawStateDatabaseForTest();
}
});
it("fails closed for an unqueued media-only final", async () => {
const sessionsDir = await makeSessionsDir();
await writeMainSession({
sessionsDir,
pendingFinalDelivery: {
kind: "transport-only",
createdAt: Date.now(),
intentId: "intent-media-only",
deliveries: [{ id: "delivery-media-only", state: "prepared" }],
context: discordDeliveryContext,
},
});
await expectRecovery({ recovered: 0, failed: 1, skipped: 0 });
expect(callGateway).not.toHaveBeenCalled();
expect(sendRecoveryNotice).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringContaining("ask for any missing remainder") }),
);
});
it("fails visibly instead of replaying part of an unqueued text and media final", async () => {
const sessionsDir = await makeSessionsDir();
await writeMainSession({
sessionsDir,
pendingFinalDelivery: {
kind: "transport-only",
createdAt: Date.now(),
context: discordDeliveryContext,
intentId: "intent-text-media",
deliveries: [
{ id: "delivery-text", state: "prepared" },
{ id: "delivery-media", state: "prepared" },
],
},
});
await expectRecovery({ recovered: 0, failed: 1, skipped: 0 });
expect(callGateway).not.toHaveBeenCalled();
expect(sendRecoveryNotice).toHaveBeenCalledOnce();
});
it.each(["delivered", "unknown"] as const)(
"fails closed when a %s delivery is mixed with prepared work",
async (state) => {
const sessionsDir = await makeSessionsDir();
await writeMainSession({
sessionsDir,
pendingFinalDelivery: makePendingFinalDelivery("Do not regenerate this aggregate.", {
context: discordDeliveryContext,
intentId: `intent-mixed-${state}`,
deliveries: [
{ id: `delivery-${state}`, state },
{ id: "delivery-still-prepared", state: "prepared" },
],
}),
});
await expectRecovery({ recovered: 0, failed: 1, skipped: 0 });
expect(callGateway).not.toHaveBeenCalled();
expect(sendRecoveryNotice).toHaveBeenCalledOnce();
expect(sendRecoveryNotice).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("ask for any missing remainder"),
}),
);
expect(sendRecoveryNotice.mock.calls[0]?.[0].text).not.toContain("send that last request");
},
);
it.each(["pending", "failed", "completed"] as const)(
"does not regenerate a prepared pending final while its exact queue owner is %s",
async (ownerStatus) => {
const deliveryId = `delivery-owner-${ownerStatus}`;
try {
await enqueueDeliveryOnce(
{
channel: "discord",
to: "discord:dm:123",
payloads: [{ text: "Queue owns this final." }],
queuePolicy: "required",
completionRetention: "permanent",
},
deliveryId,
tmpDir,
);
if (ownerStatus === "failed") {
moveDeliveryQueueEntryToFailed(OUTBOUND_DELIVERY_QUEUE_NAME, deliveryId, tmpDir);
} else if (ownerStatus === "completed") {
await ackDelivery(deliveryId, tmpDir);
}
const sessionsDir = await makeSessionsDir();
await writeMainSession({
sessionsDir,
pendingFinalDelivery: makePendingFinalDelivery("Queue owns this final.", {
context: discordDeliveryContext,
intentId: `intent-owner-${ownerStatus}`,
deliveries: [{ id: deliveryId, state: "prepared" }],
}),
});
await expectRecovery({ recovered: 0, failed: 1, skipped: 0 });
expect(callGateway).not.toHaveBeenCalled();
if (ownerStatus === "pending") {
expect(sendRecoveryNotice).not.toHaveBeenCalled();
} else {
expect(sendRecoveryNotice).toHaveBeenCalledOnce();
}
} finally {
closeOpenClawStateDatabaseForTest();
}
},
);
it("keeps a hook-owned pending final behind the unsafe-hook gate after claim cleanup", async () => {
const sessionsDir = await makeSessionsDir();
const storePath = path.join(sessionsDir, "sessions.json");
@@ -2186,9 +1956,11 @@ describe("main-session-restart-recovery", () => {
].join("\n");
await writeMainSession({
sessionsDir,
pendingFinalDelivery: makePendingFinalDelivery(pendingPayload, {
pendingFinalDelivery: {
kind: "replayable",
text: pendingPayload,
createdAt: Date.now() - 5_000,
}),
},
});
await writeTranscript(sessionsDir, "main-session", [
{ role: "user", content: "calculate the answer" },
@@ -1,45 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { persistPendingFinalDeliveryMarker } from "./pending-final-delivery-marker.js";
const state = vi.hoisted(() => ({ persistSessionEntry: vi.fn() }));
vi.mock("./command/attempt-execution.shared.js", () => ({
persistSessionEntry: (...args: unknown[]) => state.persistSessionEntry(...args),
}));
describe("persistPendingFinalDeliveryMarker", () => {
beforeEach(() => {
state.persistSessionEntry
.mockReset()
.mockImplementation(async (params: { entry: SessionEntry }) => params.entry);
});
it("owns a multi-payload command delivery as one durable batch", async () => {
const entry: SessionEntry = { sessionId: "session-1", updatedAt: 1 };
const payloads = [{ text: "first" }, { text: "second" }];
const result = await persistPendingFinalDeliveryMarker({
deliver: true,
sessionStore: { main: entry },
sessionKey: "main",
sessionEntry: entry,
storePath: "/tmp/sessions.json",
suppressVisibleSessionEffects: false,
sessionReboundDuringRun: false,
payloads,
runOwnedSessionId: "session-1",
});
expect(result.sessionEntry?.pendingFinalDelivery?.deliveries).toEqual([
{ id: expect.any(String), state: "prepared" },
]);
const deliveryId = result.sessionEntry?.pendingFinalDelivery?.deliveries?.[0]?.id;
expect(
payloads.map(
(payload) => getReplyPayloadMetadata(payload)?.pendingFinalDeliveryCompletion?.deliveryId,
),
).toEqual([deliveryId, deliveryId]);
});
});
+11 -36
View File
@@ -1,6 +1,5 @@
/** Persists restart-recoverable final delivery markers for agent runs. */
import { randomUUID } from "node:crypto";
import { setReplyPayloadMetadata, type ReplyPayload } from "../auto-reply/reply-payload.js";
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import {
buildRecoverablePendingFinalDeliveryText,
normalizePendingFinalDeliveryPayloads,
@@ -27,20 +26,15 @@ type PersistPendingFinalDeliveryMarkerParams = {
type PendingFinalDeliveryMarkerResult = {
sessionEntry?: SessionEntry;
pendingFinalDeliveryMarkerPersisted: boolean;
pendingFinalDeliveryIntentId?: string;
hasSendableFinalPayload: boolean;
};
export async function persistPendingFinalDeliveryMarker(
params: PersistPendingFinalDeliveryMarkerParams,
): Promise<PendingFinalDeliveryMarkerResult> {
const sendablePayloads = params.payloads.filter(
(payload) => normalizePendingFinalDeliveryPayloads([payload]).length > 0,
);
const hasSendableFinalPayload = sendablePayloads.length > 0;
const recoverableText = buildRecoverablePendingFinalDeliveryText(
normalizePendingFinalRecoveryPayloads(params.payloads),
);
const recoveryPayloads = normalizePendingFinalRecoveryPayloads(params.payloads);
const hasSendableFinalPayload = normalizePendingFinalDeliveryPayloads(params.payloads).length > 0;
const recoverableText = buildRecoverablePendingFinalDeliveryText(recoveryPayloads);
if (
!params.deliver ||
@@ -48,7 +42,9 @@ export async function persistPendingFinalDeliveryMarker(
!params.sessionKey ||
params.suppressVisibleSessionEffects ||
params.sessionReboundDuringRun ||
params.payloads.length === 0 ||
isSubagentSessionKey(params.sessionKey) ||
!recoverableText ||
!hasSendableFinalPayload
) {
return {
@@ -68,8 +64,6 @@ export async function persistPendingFinalDeliveryMarker(
}
const now = Date.now();
const intentId = randomUUID();
const deliveryId = randomUUID();
const persisted = await persistSessionEntry({
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
@@ -78,11 +72,8 @@ export async function persistPendingFinalDeliveryMarker(
entry: {
...entry,
pendingFinalDelivery: {
...(recoverableText
? { kind: "replayable" as const, text: recoverableText }
: { kind: "transport-only" as const }),
intentId,
deliveries: [{ id: deliveryId, state: "prepared" as const }],
kind: "replayable",
text: recoverableText,
createdAt: now,
...(params.deliveryContext ? { context: params.deliveryContext } : {}),
},
@@ -91,29 +82,13 @@ export async function persistPendingFinalDeliveryMarker(
shouldPersist: (current) =>
current?.sessionId === params.runOwnedSessionId && current.abortedLastRun !== true,
});
const markerPersisted = persisted?.pendingFinalDelivery?.intentId === intentId;
if (markerPersisted) {
for (const payload of sendablePayloads) {
setReplyPayloadMetadata(payload, {
pendingFinalDeliveryCompletion: {
deliveryId,
intentId,
...(entry.restartRecoveryDeliveryRunId
? { recoveryRunId: entry.restartRecoveryDeliveryRunId }
: {}),
sessionId: params.runOwnedSessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
},
});
}
}
const markerPersisted =
persisted?.pendingFinalDelivery?.kind === "replayable" &&
persisted.pendingFinalDelivery.text === recoverableText;
return {
sessionEntry: persisted,
pendingFinalDeliveryMarkerPersisted: markerPersisted,
...(markerPersisted ? { pendingFinalDeliveryIntentId: intentId } : {}),
hasSendableFinalPayload,
};
}
+4 -9
View File
@@ -245,15 +245,10 @@ export type ReplyPayloadMetadata = {
};
/** Opaque owner for one final-delivery transcript capture on a shared dispatcher. */
finalDeliveryCapture?: object;
/** Exact persisted delivery owner; WeakMap-only and never serialized. */
pendingFinalDeliveryCompletion?: {
deliveryId: string;
intentId: string;
recoveryRunId?: string;
sessionId: string;
sessionKey: string;
storePath: string;
};
/** Durable pending-final intent represented by this runtime payload. */
pendingFinalDeliveryIntentId?: string;
/** Restart-safe text this payload contributes to its pending-final intent. */
pendingFinalDeliveryRetryText?: string;
/** replyToId existed before reply threading could inject an implicit target. */
replyToIdExplicit?: boolean;
/** Canonical reply policy used by both message-tool dedupe and final delivery routing. */
+17 -1
View File
@@ -19,6 +19,7 @@ import {
normalizeDeliveryContext,
} from "../../utils/delivery-context.shared.js";
import { resolveFallbackTransition } from "../fallback-state.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import {
isReplyPayloadStatusNotice,
markReplyPayloadForSourceSuppressionDelivery,
@@ -36,7 +37,10 @@ import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveEffectiveReplyRoute } from "./effective-reply-route.js";
import type { InternalGetReplyOptions } from "./get-reply.types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
import { sanitizePendingFinalDeliveryText } from "./pending-final-delivery.js";
import {
buildPendingFinalDeliveryText,
sanitizePendingFinalDeliveryText,
} from "./pending-final-delivery.js";
import { type FollowupRun, type QueueSettings, scheduleFollowupDrain } from "./queue.js";
import { normalizeReplyPayloadDirectives } from "./reply-delivery.js";
import { type ReplyOperation, runAfterReplyOperationClear } from "./reply-run-registry.js";
@@ -73,6 +77,18 @@ export function markBeforeAgentRunBlockedPayloads(payloads: ReplyPayload[]): Rep
);
}
export function resolvePendingFinalDeliveryRetryText(params: {
isHeartbeat: boolean;
payload: ReplyPayload;
}): string {
const pendingText = buildPendingFinalDeliveryText([params.payload]);
if (!params.isHeartbeat) {
return pendingText;
}
const stripped = stripHeartbeatToken(pendingText, { mode: "message" });
return stripped.shouldSkip ? "" : stripped.text || pendingText;
}
export function buildSilentFallbackFailurePayload(params: {
fallbackTransition: ReturnType<typeof resolveFallbackTransition>;
fallbackFailureKnown: boolean;
+2 -12
View File
@@ -319,25 +319,15 @@ export async function executePreparedReplyAgentRun(
});
if (!sourceReplyPolicy.suppressDelivery) {
const pendingFinalDeliveryIntentId = crypto.randomUUID();
const pendingFinalDeliveryDeliveryId = crypto.randomUUID();
setReplyPayloadMetadata(hookReply, {
pendingFinalDeliveryCompletion: {
deliveryId: pendingFinalDeliveryDeliveryId,
intentId: pendingFinalDeliveryIntentId,
...(activeSessionEntry?.restartRecoveryDeliveryRunId
? { recoveryRunId: activeSessionEntry.restartRecoveryDeliveryRunId }
: {}),
sessionId: replyOperation.sessionId,
sessionKey,
storePath,
},
pendingFinalDeliveryIntentId,
pendingFinalDeliveryRetryText: hookFinalDeliveryText,
});
hookCheckpoint = {
state: hookFinalDeliveryText ? "handled-reply" : "handled-unrecoverable",
pendingFinalDelivery: {
text: hookFinalDeliveryText ?? "",
intentId: pendingFinalDeliveryIntentId,
deliveries: [{ id: pendingFinalDeliveryDeliveryId, state: "prepared" }],
context: resolveReplyRunDeliveryContext({
cfg,
sessionCtx,
@@ -14,6 +14,7 @@ import type { ReplyPayload } from "../types.js";
import {
buildInlinePluginStatusPayload,
markBeforeAgentRunBlockedPayloads,
resolvePendingFinalDeliveryRetryText,
resolveReplyRunDeliveryContext,
resolveSourceReplyPolicy,
} from "./agent-runner-core.js";
@@ -34,11 +35,7 @@ import {
mergeExecutionTrace,
} from "./agent-runner-trace.js";
import { appendUsageLine } from "./agent-runner-usage-line.js";
import {
buildRecoverablePendingFinalDeliveryText,
normalizePendingFinalDeliveryPayloads,
normalizePendingFinalRecoveryPayloads,
} from "./pending-final-delivery.js";
import { buildPendingFinalDeliveryText } from "./pending-final-delivery.js";
import { readPostCompactionContext } from "./post-compaction-context.js";
import { warnPrivateMessageToolFinal } from "./private-message-tool-final.js";
import { enqueueFollowupRun, refreshQueuedFollowupSession } from "./queue.js";
@@ -313,9 +310,10 @@ export async function completeReplyAgentRun(input: {
runtimePolicySessionKey,
opts,
});
const finalDeliveryText = buildPendingFinalDeliveryText(finalPayloads);
// #85714: warn only for unusually substantive private final text. In
// message_tool_only, no tool call can be intentional silence, and
// final payloads also include verbose/status/usage metadata.
// finalDeliveryText also includes verbose/status/usage metadata.
const assistantFinalText = normalizeAssistantFinalDeliveryText(
typeof runResult.meta?.finalAssistantVisibleText === "string"
? runResult.meta.finalAssistantVisibleText
@@ -359,12 +357,7 @@ export async function completeReplyAgentRun(input: {
finalPayloads = [...finalPayloads, buildStrandedReplyDeliveryFailurePayload()];
}
}
const recoverablePendingFinalText = buildRecoverablePendingFinalDeliveryText(
normalizePendingFinalRecoveryPayloads(finalPayloads),
);
const pendingText = sourceReplyPolicy.suppressDelivery
? ""
: (recoverablePendingFinalText ?? "");
const pendingText = sourceReplyPolicy.suppressDelivery ? "" : finalDeliveryText;
const heartbeatAckMaxChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
const resolvedPendingText = isHeartbeat
? (() => {
@@ -375,30 +368,17 @@ export async function completeReplyAgentRun(input: {
return stripped.shouldSkip ? "" : stripped.text || pendingText;
})()
: pendingText;
const sendableFinalPayloads = sourceReplyPolicy.suppressDelivery
? []
: finalPayloads.filter(
(payload) => normalizePendingFinalDeliveryPayloads([payload]).length > 0,
);
if (sendableFinalPayloads.length > 0) {
if (resolvedPendingText) {
const pendingFinalDeliveryIntentId = crypto.randomUUID();
const expectedSessionId = activeSessionEntry?.sessionId ?? followupRun.run.sessionId;
const pendingFinalDeliveries = sendableFinalPayloads.map((payload) => {
const deliveryId = crypto.randomUUID();
for (const payload of finalPayloads) {
setReplyPayloadMetadata(payload, {
pendingFinalDeliveryCompletion: {
deliveryId,
intentId: pendingFinalDeliveryIntentId,
...(activeSessionEntry?.restartRecoveryDeliveryRunId
? { recoveryRunId: activeSessionEntry.restartRecoveryDeliveryRunId }
: {}),
sessionId: expectedSessionId,
sessionKey,
storePath,
},
pendingFinalDeliveryIntentId,
pendingFinalDeliveryRetryText: resolvePendingFinalDeliveryRetryText({
isHeartbeat,
payload,
}),
});
return { id: deliveryId, state: "prepared" as const };
});
}
const pendingFinalDeliveryContext = resolveReplyRunDeliveryContext({
cfg,
sessionCtx,
@@ -407,6 +387,7 @@ export async function completeReplyAgentRun(input: {
runtimePolicySessionKey,
opts,
});
const expectedSessionId = activeSessionEntry?.sessionId ?? followupRun.run.sessionId;
// A reset can rebind the key while the model runs; its replacement must
// never inherit the old run's final or advertise an uncommitted intent.
const persistedPendingFinalDelivery = await updateSessionEntry(
@@ -415,11 +396,9 @@ export async function completeReplyAgentRun(input: {
entry.sessionId === expectedSessionId
? {
pendingFinalDelivery: {
...(resolvedPendingText
? { kind: "replayable" as const, text: resolvedPendingText }
: { kind: "transport-only" as const }),
kind: "replayable" as const,
text: resolvedPendingText,
intentId: pendingFinalDeliveryIntentId,
deliveries: pendingFinalDeliveries,
context: pendingFinalDeliveryContext,
createdAt: Date.now(),
},
@@ -1588,84 +1588,16 @@ describe("runReplyAgent pending final delivery capture", () => {
kind: "replayable",
text: "visible final",
intentId: expect.any(String),
deliveries: [{ id: expect.any(String), state: "prepared" }],
});
const visiblePayload = (Array.isArray(result) ? result : [result]).find(
(payload) => payload?.text === "visible final",
);
expect(getReplyPayloadMetadata(visiblePayload ?? {})).toMatchObject({
pendingFinalDeliveryCompletion: {
deliveryId: stored.pendingFinalDelivery?.deliveries?.[0]?.id,
intentId: stored.pendingFinalDelivery?.intentId,
sessionId: "session",
sessionKey: "main",
storePath,
},
pendingFinalDeliveryIntentId: stored.pendingFinalDelivery?.intentId,
pendingFinalDeliveryRetryText: "visible final",
});
});
it("owns a media-only final with its complete replay directive", async () => {
const { sessionEntry, sessionStore, storePath } = await makeSessionFixture();
state.runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ mediaUrl: "https://example.test/final.png" }],
meta: {},
});
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
const result = await run();
const stored = await readStoredMainSession(storePath);
expect(stored.pendingFinalDelivery).toMatchObject({
kind: "replayable",
text: "MEDIA:https://example.test/final.png",
intentId: expect.any(String),
deliveries: [{ id: expect.any(String), state: "prepared" }],
});
const payload = Array.isArray(result) ? result[0] : result;
expect(getReplyPayloadMetadata(payload ?? {})).toMatchObject({
pendingFinalDeliveryCompletion: {
deliveryId: stored.pendingFinalDelivery?.deliveries?.[0]?.id,
intentId: stored.pendingFinalDelivery?.intentId,
},
});
});
it("owns mixed text and media finals without replaying a partial aggregate", async () => {
const { sessionEntry, sessionStore, storePath } = await makeSessionFixture();
state.runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "visible text" }, { mediaUrl: "https://example.test/final.png" }],
meta: {},
});
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
const result = await run();
const payloads = Array.isArray(result) ? result : [result];
const stored = await readStoredMainSession(storePath);
expect(stored.pendingFinalDelivery).toMatchObject({
kind: "transport-only",
deliveries: [
{ id: expect.any(String), state: "prepared" },
{ id: expect.any(String), state: "prepared" },
],
});
expect(stored.pendingFinalDelivery).not.toHaveProperty("text");
expect(
payloads.map(
(payload) =>
getReplyPayloadMetadata(payload ?? {})?.pendingFinalDeliveryCompletion?.deliveryId,
),
).toEqual(stored.pendingFinalDelivery?.deliveries?.map(({ id }) => id));
});
it("persists canonical SQLite pending final delivery after its intent commits", async () => {
const sessionKey = "agent:main:main";
const { sessionEntry, sessionStore, storePath } = await makeSessionFixture({}, sessionKey);
@@ -1688,19 +1620,13 @@ describe("runReplyAgent pending final delivery capture", () => {
kind: "replayable",
intentId: expect.any(String),
text: "visible canonical final",
deliveries: [{ id: expect.any(String), state: "prepared" }],
},
sessionId: "session",
});
const visiblePayload = Array.isArray(result) ? result[0] : result;
expect(getReplyPayloadMetadata(visiblePayload ?? {})).toMatchObject({
pendingFinalDeliveryCompletion: {
deliveryId: stored?.pendingFinalDelivery?.deliveries?.[0]?.id,
intentId: stored?.pendingFinalDelivery?.intentId,
sessionId: "session",
sessionKey,
storePath,
},
pendingFinalDeliveryIntentId: stored?.pendingFinalDelivery?.intentId,
pendingFinalDeliveryRetryText: "visible canonical final",
});
expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce();
});
@@ -2931,17 +2857,11 @@ describe("runReplyAgent pending final delivery capture", () => {
expect(stored.pendingFinalDelivery).toMatchObject({
kind: "replayable",
text: longRemainder,
deliveries: [{ id: expect.any(String), state: "prepared" }],
});
const payload = Array.isArray(result) ? result[0] : result;
expect(getReplyPayloadMetadata(payload ?? {})).toMatchObject({
pendingFinalDeliveryCompletion: {
deliveryId: stored.pendingFinalDelivery?.deliveries?.[0]?.id,
intentId: stored.pendingFinalDelivery?.intentId,
sessionId: "session",
sessionKey: "main",
storePath,
},
pendingFinalDeliveryIntentId: stored.pendingFinalDelivery?.intentId,
pendingFinalDeliveryRetryText: longRemainder,
});
});
});
+1 -205
View File
@@ -1,11 +1,5 @@
// Tests before-deliver hook ordering and payload mutation behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import type { InternalSessionEntry } from "../../config/sessions/types.js";
import { describe, expect, it } from "vitest";
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
import type { ReplyPayload } from "../types.js";
import {
@@ -15,40 +9,6 @@ import {
createReplyDispatcher,
} from "./reply-dispatcher.js";
async function makePendingFinalFixture() {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dispatcher-pending-final-"));
const storePath = path.join(tmpDir, "sessions.json");
const sessionKey = "agent:main:telegram:direct:123";
await replaceSessionEntry(
{ sessionKey, storePath },
{
sessionId: "session-1",
status: "running",
updatedAt: Date.now(),
pendingFinalDelivery: {
kind: "replayable",
text: "final answer",
createdAt: Date.now(),
intentId: "intent-1",
deliveries: [{ id: "delivery-1", state: "prepared" }],
},
},
);
const payload = setReplyPayloadMetadata(
{ text: "final answer" },
{
pendingFinalDeliveryCompletion: {
deliveryId: "delivery-1",
intentId: "intent-1",
sessionId: "session-1",
sessionKey,
storePath,
},
},
);
return { payload, sessionKey, storePath, tmpDir };
}
describe("beforeDeliver in reply dispatcher", () => {
it("delivers the attached fallback when the primary payload is cancelled", async () => {
const delivered: string[] = [];
@@ -341,168 +301,4 @@ describe("beforeDeliver in reply dispatcher", () => {
expect(delivered).toEqual(["plain reply"]);
});
it("records direct-delivery custody before waiting for the channel provider", async () => {
const fixture = await makePendingFinalFixture();
const enteredProvider = createDeferred();
const releaseProvider = createDeferred();
try {
const dispatcher = createReplyDispatcher({
deliver: async () => {
enteredProvider.resolve();
await releaseProvider.promise;
},
});
dispatcher.sendFinalReply(fixture.payload);
dispatcher.markComplete();
await enteredProvider.promise;
expect(
(
loadSessionEntry({
sessionKey: fixture.sessionKey,
storePath: fixture.storePath,
}) as InternalSessionEntry
)?.pendingFinalDelivery?.deliveries,
).toEqual([{ id: "delivery-1", state: "queued" }]);
releaseProvider.resolve();
await dispatcher.waitForIdle();
expect(
(
loadSessionEntry({
sessionKey: fixture.sessionKey,
storePath: fixture.storePath,
}) as InternalSessionEntry
)?.pendingFinalDelivery?.deliveries,
).toEqual([{ id: "delivery-1", state: "delivered" }]);
} finally {
releaseProvider.resolve();
await fs.rm(fixture.tmpDir, { recursive: true, force: true });
}
});
it.each([
{
label: "proven pre-send failure",
error: () =>
Object.assign(new Error("connect failed"), { code: "ECONNREFUSED", syscall: "connect" }),
expected: "prepared",
},
{
label: "ambiguous provider failure",
error: () => new Error("send outcome unknown"),
expected: "unknown",
},
] as const)("records $label before reporting the error", async ({ error, expected }) => {
const fixture = await makePendingFinalFixture();
try {
const dispatcher = createReplyDispatcher({
deliver: async () => {
throw error();
},
});
dispatcher.sendFinalReply(fixture.payload);
dispatcher.markComplete();
await dispatcher.waitForIdle();
expect(
(
loadSessionEntry({
sessionKey: fixture.sessionKey,
storePath: fixture.storePath,
}) as InternalSessionEntry
)?.pendingFinalDelivery?.deliveries,
).toEqual([{ id: "delivery-1", state: expected }]);
} finally {
await fs.rm(fixture.tmpDir, { recursive: true, force: true });
}
});
it("suppresses a second direct call after the exact delivery is terminal", async () => {
const fixture = await makePendingFinalFixture();
const deliver = vi.fn(async () => {});
try {
const first = createReplyDispatcher({ deliver });
first.sendFinalReply(fixture.payload);
first.markComplete();
await first.waitForIdle();
const second = createReplyDispatcher({ deliver });
second.sendFinalReply(fixture.payload);
second.markComplete();
await second.waitForIdle();
expect(deliver).toHaveBeenCalledOnce();
expect(second.getCancelledCounts?.().final).toBe(1);
} finally {
await fs.rm(fixture.tmpDir, { recursive: true, force: true });
}
});
it("suppresses a direct call whose persisted owner was replaced", async () => {
const fixture = await makePendingFinalFixture();
const current = loadSessionEntry({
sessionKey: fixture.sessionKey,
storePath: fixture.storePath,
}) as InternalSessionEntry;
await replaceSessionEntry(
{ sessionKey: fixture.sessionKey, storePath: fixture.storePath },
{
...current,
pendingFinalDelivery: {
...current.pendingFinalDelivery!,
intentId: "replacement-intent",
},
},
);
const deliver = vi.fn(async () => {});
try {
const dispatcher = createReplyDispatcher({ deliver });
dispatcher.sendFinalReply(fixture.payload);
dispatcher.markComplete();
await dispatcher.waitForIdle();
expect(deliver).not.toHaveBeenCalled();
expect(dispatcher.getCancelledCounts?.().final).toBe(1);
} finally {
await fs.rm(fixture.tmpDir, { recursive: true, force: true });
}
});
it("records policy suppression before awaiting cancellation observers", async () => {
const fixture = await makePendingFinalFixture();
const observerStarted = createDeferred();
const releaseObserver = createDeferred();
try {
const dispatcher = createReplyDispatcher({
beforeDeliver: () => null,
deliver: async () => {},
onBeforeDeliverCancelled: async () => {
observerStarted.resolve();
await releaseObserver.promise;
},
});
dispatcher.sendFinalReply(fixture.payload);
dispatcher.markComplete();
await observerStarted.promise;
expect(
(
loadSessionEntry({
sessionKey: fixture.sessionKey,
storePath: fixture.storePath,
}) as InternalSessionEntry
)?.pendingFinalDelivery?.deliveries,
).toEqual([{ id: "delivery-1", state: "suppressed" }]);
releaseObserver.resolve();
await dispatcher.waitForIdle();
} finally {
releaseObserver.resolve();
await fs.rm(fixture.tmpDir, { recursive: true, force: true });
}
});
});
@@ -21,7 +21,8 @@ import {
} from "./dispatch-from-config.payloads.js";
import {
clearPendingFinalDeliveryAfterSuccess,
suppressPendingFinalDelivery,
capturePendingFinalDeliveryIdentity,
reconcilePendingFinalDeliveryAfterSettlement,
} from "./dispatch-from-config.pending-final.js";
import type { ReplyDispatchDeliveryOutcome } from "./reply-dispatcher.js";
@@ -47,15 +48,26 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
sendPolicyDenied,
sessionAgentId,
sessionKey,
sessionStoreEntry,
suppressDelivery,
throwIfDispatchOperationAborted,
turnLedger,
waitForPendingDirectBlockReplyDelivery,
} = state;
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
const pendingFinalDeliveryIdentity = replies
.map((reply) => getReplyPayloadMetadata(reply)?.pendingFinalDeliveryCompletion)
.find((completion) => completion !== undefined);
const pendingFinalDelivery = {
storePath: sessionStoreEntry.storePath,
sessionKey: sessionStoreEntry.sessionKey ?? sessionKey,
};
const replyPendingIntentIds = new Set(
replies
.map((reply) => getReplyPayloadMetadata(reply)?.pendingFinalDeliveryIntentId)
.filter((intentId): intentId is string => Boolean(intentId)),
);
const pendingFinalDeliveryIdentity = capturePendingFinalDeliveryIdentity({
...pendingFinalDelivery,
intentId: replyPendingIntentIds.size === 1 ? [...replyPendingIntentIds][0] : undefined,
});
// Final delivery is outside the progress wrappers. Wait until every source-ordered callback
// has at least started so a delayed tool/reasoning transition cannot appear after the final.
if (state.preserveProgressCallbackStartOrder) {
@@ -72,7 +84,10 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
let routedFinalCount = 0;
let attemptedFinalDelivery = false;
let finalDeliveryFailed = false;
const finalDeliveries: Promise<ReplyDispatchDeliveryOutcome>[] = [];
const finalDeliveries: Array<{
outcome: Promise<ReplyDispatchDeliveryOutcome>;
payload: ReplyPayload;
}> = [];
let allQueuedFinalsObserved = true;
const sentFinalPayloadDedupeKeys = new Set<string>();
let deferredTtsTextPending = state.progressState.accumulatedBlockTtsText;
@@ -81,11 +96,9 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
// Durable reasoning is a channel-owned lane; generic channels keep the
// historical suppression unless they explicitly opt in.
if (reply.isReasoning === true && !state.reasoningPayloadsEnabled) {
await suppressPendingFinalDelivery(reply);
continue;
}
if (reply.isCommentary === true && !state.commentaryPayloadsEnabled) {
await suppressPendingFinalDelivery(reply);
continue;
}
if (suppressDelivery && !shouldDeliverDespiteSourceReplySuppression(reply, state)) {
@@ -103,12 +116,10 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
].join(" "),
);
}
await suppressPendingFinalDelivery(reply);
continue;
}
const finalPayloadDedupeKey = createFinalDispatchPayloadDedupeKey(reply);
if (sentFinalPayloadDedupeKeys.has(finalPayloadDedupeKey)) {
await suppressPendingFinalDelivery(reply);
continue;
}
sentFinalPayloadDedupeKeys.add(finalPayloadDedupeKey);
@@ -130,7 +141,6 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
}
if (finalReply.dedupedAgainstBlock) {
// The delivering block already settled into the turn ledger.
await suppressPendingFinalDelivery(reply);
continue;
}
attemptedFinalDelivery = true;
@@ -138,7 +148,7 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
routedFinalCount += finalReply.routedFinalCount;
if (finalReply.queuedFinal) {
if (finalReply.dispatcherOutcome) {
finalDeliveries.push(finalReply.dispatcherOutcome);
finalDeliveries.push({ outcome: finalReply.dispatcherOutcome, payload: reply });
} else {
allQueuedFinalsObserved = false;
}
@@ -152,9 +162,19 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
if (queuedFinal && allQueuedFinalsObserved) {
// Delivery observers run from the queue itself, so direct low-level callers
// reconcile too; the settle task only makes lifecycle owners await it.
const reconcilePendingFinal = Promise.all(finalDeliveries)
.then(async () => {
await clearPendingFinalDeliveryAfterSuccess(pendingFinalDeliveryIdentity);
const reconcilePendingFinal = Promise.all(
finalDeliveries.map(async (delivery) => ({
outcome: await delivery.outcome,
payload: delivery.payload,
})),
)
.then(async (deliveries) => {
await reconcilePendingFinalDeliveryAfterSettlement({
...pendingFinalDelivery,
deliveries,
identity: pendingFinalDeliveryIdentity,
replies,
});
})
.catch((error: unknown) => {
logVerbose(
@@ -165,7 +185,10 @@ export async function finalizeDispatchAndAudit(state: ExecuteDispatchReadyState)
} else {
// Routed delivery has a transport result already. Custom dispatchers that
// do not expose the core observer retain the legacy queue-admission behavior.
await clearPendingFinalDeliveryAfterSuccess(pendingFinalDeliveryIdentity);
await clearPendingFinalDeliveryAfterSuccess({
...pendingFinalDelivery,
identity: pendingFinalDeliveryIdentity,
});
}
// Register successful queued cleanup before honoring a late abort. The
// outer settle owner still runs it from finally (#89115).
@@ -4,14 +4,11 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js";
import type { ReplyPayload } from "../reply-payload.js";
import {
getReplyPayloadMetadata,
setReplyPayloadMetadata,
type ReplyPayload,
} from "../reply-payload.js";
import {
capturePendingFinalDeliveryIdentity,
clearPendingFinalDeliveryAfterSuccess,
suppressPendingFinalDelivery,
reconcilePendingFinalDeliveryAfterSettlement,
} from "./dispatch-from-config.pending-final.js";
import { retireTerminalRestartRecoverySourceClaim } from "./restart-recovery-claim.js";
@@ -31,7 +28,6 @@ describe("pending final delivery restart proof", () => {
async function writePendingFinal(
beforeAgentReplyState: "continue" | "handled-reply",
state: "prepared" | "delivered" = "delivered",
): Promise<void> {
const entry: SessionEntry = {
sessionId: "session",
@@ -44,7 +40,6 @@ describe("pending final delivery restart proof", () => {
text: "hook reply",
createdAt: 1,
intentId: "intent-1",
deliveries: [{ id: "delivery-1", state }],
},
restartRecoveryBeforeAgentReplyState: beforeAgentReplyState,
restartRecoveryForceSafeTools: beforeAgentReplyState === "handled-reply" ? true : undefined,
@@ -53,28 +48,17 @@ describe("pending final delivery restart proof", () => {
await replaceSessionEntry({ storePath, sessionKey }, entry);
}
function pendingFinalPayload(deliveryId = "delivery-1"): ReplyPayload {
const payload: ReplyPayload = { text: "hook reply" };
setReplyPayloadMetadata(payload, {
pendingFinalDeliveryCompletion: {
deliveryId,
intentId: "intent-1",
sessionId: "session",
sessionKey,
storePath,
},
});
return payload;
}
it.each(["continue", "handled-reply"] as const)(
"clears %s provenance only after the exact pending intent succeeds",
async (beforeAgentReplyState) => {
await writePendingFinal(beforeAgentReplyState);
const identity =
getReplyPayloadMetadata(pendingFinalPayload())?.pendingFinalDeliveryCompletion;
const identity = capturePendingFinalDeliveryIdentity({
intentId: "intent-1",
sessionKey,
storePath,
});
await clearPendingFinalDeliveryAfterSuccess(identity);
await clearPendingFinalDeliveryAfterSuccess({ identity, sessionKey, storePath });
const entry = loadSessionEntry({ sessionKey, storePath }) as SessionEntry | undefined;
expect(entry?.pendingFinalDelivery).toBeUndefined();
@@ -103,25 +87,18 @@ describe("pending final delivery restart proof", () => {
kind: "transport-only",
createdAt: Date.now(),
intentId: "intent-media",
deliveries: [{ id: "delivery-media", state: "delivered" }],
},
restartRecoveryBeforeAgentReplyState: "handled-unrecoverable",
restartRecoverySourceIngress: "channel",
};
await replaceSessionEntry({ storePath, sessionKey }, entry);
const payload: ReplyPayload = { mediaUrl: "https://example.test/image.png" };
setReplyPayloadMetadata(payload, {
pendingFinalDeliveryCompletion: {
deliveryId: "delivery-media",
intentId: "intent-media",
sessionId: "session",
sessionKey,
storePath,
},
const identity = capturePendingFinalDeliveryIdentity({
intentId: "intent-media",
sessionKey,
storePath,
});
const identity = getReplyPayloadMetadata(payload)?.pendingFinalDeliveryCompletion;
await clearPendingFinalDeliveryAfterSuccess(identity);
await clearPendingFinalDeliveryAfterSuccess({ identity, sessionKey, storePath });
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
status: "done",
@@ -132,40 +109,32 @@ describe("pending final delivery restart proof", () => {
).toBeUndefined();
});
it("clears a skipped turn only after every sendable final is suppressed", async () => {
await writePendingFinal("continue", "prepared");
await replaceSessionEntry(
{ storePath, sessionKey },
{
...(loadSessionEntry({ sessionKey, storePath }) as SessionEntry),
pendingFinalDelivery: {
kind: "replayable",
text: "hook reply",
createdAt: 1,
intentId: "intent-1",
deliveries: [
{ id: "delivery-1", state: "prepared" },
{ id: "delivery-2", state: "prepared" },
],
},
it("keeps normal-turn provenance when transport fails before delivery", async () => {
await writePendingFinal("continue");
const identity = capturePendingFinalDeliveryIdentity({
intentId: "intent-1",
sessionKey,
storePath,
});
const payload: ReplyPayload = { text: "hook reply" };
await reconcilePendingFinalDeliveryAfterSettlement({
deliveries: [{ outcome: "failed-before-deliver", payload }],
identity,
replies: [payload],
sessionKey,
storePath,
});
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
pendingFinalDelivery: {
kind: "replayable",
text: "hook reply",
intentId: "intent-1",
},
);
await suppressPendingFinalDelivery(pendingFinalPayload("delivery-1"));
expect(
(loadSessionEntry({ sessionKey, storePath }) as SessionEntry).pendingFinalDelivery
?.deliveries,
).toEqual([
{ id: "delivery-1", state: "suppressed" },
{ id: "delivery-2", state: "prepared" },
]);
await suppressPendingFinalDelivery(pendingFinalPayload("delivery-2"));
expect(
(loadSessionEntry({ sessionKey, storePath }) as SessionEntry).pendingFinalDelivery,
).toBeUndefined();
restartRecoveryBeforeAgentReplyState: "continue",
restartRecoverySourceIngress: "channel",
});
});
it("does not retire a source while its terminal provider outcome is unknown", async () => {
@@ -1,75 +1,245 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { buildRestartRecoveryClaimCleanupPatch } from "../../config/sessions/restart-recovery-state.js";
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
import { settlePendingFinalDelivery } from "../../infra/outbound/delivery-completion.js";
import {
getReplyPayloadMetadata,
type ReplyPayload,
type ReplyPayloadMetadata,
} from "../reply-payload.js";
loadSessionEntryReadOnly,
updateSessionEntry,
} from "../../config/sessions/session-accessor.js";
import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js";
import type { ReplyPayload } from "../reply-payload.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import {
buildPendingFinalDeliveryText,
sanitizePendingFinalDeliveryText,
} from "./pending-final-delivery.js";
import type { ReplyDispatchDeliveryOutcome } from "./reply-dispatcher.js";
type PendingFinalDeliveryIdentity = NonNullable<
ReplyPayloadMetadata["pendingFinalDeliveryCompletion"]
>;
type SettledFinalDelivery = {
outcome: ReplyDispatchDeliveryOutcome;
payload: ReplyPayload;
};
export async function suppressPendingFinalDelivery(payload: ReplyPayload): Promise<void> {
const completion = getReplyPayloadMetadata(payload)?.pendingFinalDeliveryCompletion;
if (completion) {
await settlePendingFinalDelivery(
{ kind: "pending-final", ...completion },
"suppressed",
"prepared",
);
await clearPendingFinalDeliveryAfterSuccess(completion);
}
type PendingFinalDeliveryIdentity = {
createdAt?: number;
intentId?: string;
present: boolean;
text?: string;
};
function buildPendingFinalDeliveryCleanupPatch(entry: SessionEntry): Partial<SessionEntry> {
// An active receipt/claim may outlive outer reply settlement. Only claimless pending finals
// borrow hook provenance until their exact transport intent settles.
const clearsRestartRecoveryProof =
normalizeOptionalString(entry.restartRecoveryDeliveryRunId) === undefined;
const completesHookHandledTurn =
clearsRestartRecoveryProof &&
(entry.restartRecoveryBeforeAgentReplyState === "handled-reply" ||
entry.restartRecoveryBeforeAgentReplyState === "handled-unrecoverable");
const endedAt = completesHookHandledTurn ? Date.now() : undefined;
return {
pendingFinalDelivery: undefined,
...(clearsRestartRecoveryProof
? {
restartRecoveryBeforeAgentReplyState: undefined,
restartRecoverySourceIngress: undefined,
restartRecoveryForceSafeTools: undefined,
}
: {}),
...(endedAt !== undefined
? {
abortedLastRun: false,
endedAt,
lifecycleRunId: undefined,
runtimeMs:
typeof entry.startedAt === "number"
? Math.max(0, endedAt - entry.startedAt)
: undefined,
status: "done" as const,
}
: {}),
};
}
export async function clearPendingFinalDeliveryAfterSuccess(
identity?: PendingFinalDeliveryIdentity,
): Promise<void> {
if (!identity) {
function matchesPendingFinalDeliveryIdentity(
entry: SessionEntry,
expected: PendingFinalDeliveryIdentity,
): boolean {
const pending = entry.pendingFinalDelivery;
const currentPresent = pending !== undefined;
if (currentPresent !== expected.present) {
return false;
}
if (expected.intentId) {
return pending?.intentId === expected.intentId;
}
return (
pending?.createdAt === expected.createdAt &&
(pending?.kind === "replayable" ? pending.text : undefined) === expected.text
);
}
export async function clearPendingFinalDeliveryAfterSuccess(params: {
identity?: PendingFinalDeliveryIdentity;
storePath?: string;
sessionKey?: string;
}): Promise<void> {
const identity = params.identity;
if (!params.storePath || !params.sessionKey || !identity?.present) {
return;
}
await updateSessionEntry(
{ storePath: identity.storePath, sessionKey: identity.sessionKey },
(entry) => {
const recoveryRunId = normalizeOptionalString(entry.restartRecoveryDeliveryRunId);
const deliveries = entry.pendingFinalDelivery?.deliveries;
if (
entry.sessionId !== identity.sessionId ||
entry.pendingFinalDelivery?.intentId !== identity.intentId ||
!deliveries?.length ||
!deliveries.every(({ state }) => state === "delivered" || state === "suppressed") ||
(recoveryRunId !== undefined && recoveryRunId !== identity.recoveryRunId)
) {
{ storePath: params.storePath, sessionKey: params.sessionKey },
async (entry) => {
if (!matchesPendingFinalDeliveryIdentity(entry, identity)) {
return null;
}
if (!entry.pendingFinalDelivery) {
return null;
}
const completesHookTurn =
recoveryRunId === undefined &&
(entry.restartRecoveryBeforeAgentReplyState === "handled-reply" ||
entry.restartRecoveryBeforeAgentReplyState === "handled-unrecoverable");
const endedAt = completesHookTurn ? Date.now() : undefined;
return {
...(recoveryRunId
? buildRestartRecoveryClaimCleanupPatch({ entry, recordTerminalSource: true })
: {
restartRecoveryBeforeAgentReplyState: undefined,
restartRecoverySourceIngress: undefined,
restartRecoveryForceSafeTools: undefined,
}),
pendingFinalDelivery: undefined,
...(endedAt === undefined
? {}
: {
abortedLastRun: false,
endedAt,
lifecycleRunId: undefined,
runtimeMs:
typeof entry.startedAt === "number"
? Math.max(0, endedAt - entry.startedAt)
: undefined,
status: "done" as const,
}),
...buildPendingFinalDeliveryCleanupPatch(entry),
updatedAt: Date.now(),
};
},
{ skipMaintenance: true, takeCacheOwnership: true },
);
}
export function capturePendingFinalDeliveryIdentity(params: {
intentId?: string;
storePath?: string;
sessionKey?: string;
}): PendingFinalDeliveryIdentity | undefined {
if (!params.storePath || !params.sessionKey) {
return undefined;
}
try {
const entry = loadSessionEntryReadOnly({
storePath: params.storePath,
sessionKey: params.sessionKey,
hydrateSkillPromptRefs: false,
readConsistency: "latest",
});
const pending = entry?.pendingFinalDelivery;
if (params.intentId && pending?.intentId !== params.intentId) {
return { present: false };
}
return {
present: pending !== undefined,
intentId: params.intentId ?? pending?.intentId,
createdAt: pending?.createdAt,
text: pending?.kind === "replayable" ? pending.text : undefined,
};
} catch {
return params.intentId ? { present: true, intentId: params.intentId } : undefined;
}
}
function buildPendingFinalDeliveryRetryText(payloads: ReplyPayload[]): string {
return sanitizePendingFinalDeliveryText(
payloads
.map(
(payload) =>
getReplyPayloadMetadata(payload)?.pendingFinalDeliveryRetryText ??
buildPendingFinalDeliveryText([payload]),
)
.filter(Boolean)
.join("\n\n"),
);
}
function resolvePendingFinalDeliveryPayloads(params: {
intentId?: string;
pendingText: string;
replies: ReplyPayload[];
}): ReplyPayload[] | undefined {
const intentReplies = params.intentId
? params.replies.filter((reply) => {
const metadata = getReplyPayloadMetadata(reply);
return (
metadata?.pendingFinalDeliveryIntentId === params.intentId &&
metadata?.pendingFinalDeliveryRetryText !== undefined
);
})
: [];
const intentContributors = intentReplies.filter(
(reply) => getReplyPayloadMetadata(reply)?.pendingFinalDeliveryRetryText,
);
const intentText = buildPendingFinalDeliveryRetryText(intentContributors);
if (
intentReplies.length > 0 &&
intentText.replace(/\s+/g, " ").trim() === params.pendingText.replace(/\s+/g, " ").trim()
) {
return intentContributors;
}
const contributingReplies = params.replies.filter(
(reply) => buildPendingFinalDeliveryText([reply]) !== "",
);
if (buildPendingFinalDeliveryText(contributingReplies) === params.pendingText) {
return contributingReplies;
}
const exactMatches = contributingReplies.filter(
(reply) => buildPendingFinalDeliveryText([reply]) === params.pendingText,
);
return exactMatches.length === 1 ? exactMatches : undefined;
}
export async function reconcilePendingFinalDeliveryAfterSettlement(params: {
deliveries: SettledFinalDelivery[];
identity?: PendingFinalDeliveryIdentity;
replies: ReplyPayload[];
storePath?: string;
sessionKey?: string;
}): Promise<void> {
const identity = params.identity;
if (!params.storePath || !params.sessionKey || !identity?.present) {
return;
}
await updateSessionEntry(
{ storePath: params.storePath, sessionKey: params.sessionKey },
async (entry) => {
if (!matchesPendingFinalDeliveryIdentity(entry, identity)) {
return null;
}
const pending = entry.pendingFinalDelivery;
if (!pending) {
return null;
}
const pendingPayloads =
pending.kind === "replayable"
? resolvePendingFinalDeliveryPayloads({
intentId: identity.intentId,
pendingText: pending.text,
replies: params.replies,
})
: undefined;
const pendingPayloadSet = pendingPayloads ? new Set(pendingPayloads) : undefined;
const relevantDeliveries = pendingPayloadSet
? params.deliveries.filter((delivery) => pendingPayloadSet.has(delivery.payload))
: params.deliveries;
const ownsEveryPendingPayload =
!pendingPayloadSet || relevantDeliveries.length === pendingPayloadSet.size;
const failedBeforeDeliver = relevantDeliveries.filter(
(delivery) => delivery.outcome === "failed-before-deliver",
);
if (
relevantDeliveries.length > 0 &&
failedBeforeDeliver.length === relevantDeliveries.length
) {
return null;
}
if (pendingPayloadSet && ownsEveryPendingPayload && failedBeforeDeliver.length > 0) {
const retryText = buildPendingFinalDeliveryRetryText(
failedBeforeDeliver.map((delivery) => delivery.payload),
);
if (retryText && pending.kind === "replayable") {
return {
pendingFinalDelivery: { ...pending, text: retryText },
updatedAt: Date.now(),
};
}
}
return {
...buildPendingFinalDeliveryCleanupPatch(entry),
updatedAt: Date.now(),
};
},
@@ -59,42 +59,9 @@ function firstReplyDispatchCall() {
function pendingFinalDelivery(
text: string,
overrides: {
createdAt?: number;
context?: Record<string, unknown>;
deliveries?: Array<{
id: string;
state: "prepared" | "queued" | "delivered" | "suppressed" | "unknown";
}>;
intentId?: string;
} = {},
overrides: { createdAt?: number; context?: Record<string, unknown>; intentId?: string } = {},
) {
return {
kind: "replayable" as const,
text,
createdAt: 1,
intentId: "intent-1",
deliveries: [{ id: "delivery-1", state: "prepared" as const }],
...overrides,
};
}
function pendingFinalReply(
text: string,
overrides: { deliveryId?: string; intentId?: string } = {},
): ReplyPayload {
return setReplyPayloadMetadata(
{ text },
{
pendingFinalDeliveryCompletion: {
deliveryId: overrides.deliveryId ?? "delivery-1",
intentId: overrides.intentId ?? "intent-1",
sessionId: "session-1",
sessionKey: "agent:test:session",
storePath: "/tmp/mock-sessions.json",
},
},
);
return { kind: "replayable" as const, text, createdAt: 1, ...overrides };
}
describe("dispatchReplyFromConfig reply_dispatch hook", () => {
@@ -257,7 +224,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
it("clears pending final delivery after final dispatch succeeds", async () => {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("durable reply", {
context: { source: "heartbeat" },
@@ -272,11 +238,11 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => pendingFinalReply("durable reply"),
replyResolver: async () => ({ text: "durable reply" }),
});
await dispatcher.waitForIdle();
await vi.waitFor(() => {
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined();
expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledOnce();
});
expect(result.queuedFinal).toBe(true);
@@ -289,7 +255,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
});
expect(sessionStoreMocks.loadSessionStore).not.toHaveBeenCalled();
expect(deliver).toHaveBeenCalledOnce();
expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledTimes(3);
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined();
});
it("clears pending final delivery when abort fires after a successful final send (#89115)", async () => {
@@ -299,7 +265,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
// redelivery short-circuit silently blocks every later inbound.
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("durable reply", {
context: { source: "heartbeat" },
@@ -327,8 +292,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
cfg: emptyConfig,
dispatcher,
replyOptions: { abortSignal: abortController.signal },
replyResolver: async () =>
pendingFinalReply("durable reply", { intentId: "intent-89115" }),
replyResolver: async () => ({ text: "durable reply" }),
}),
});
@@ -337,7 +301,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
expect(dispatcher.sendFinalReply).toHaveBeenCalledOnce();
expect(deliver).toHaveBeenCalledOnce();
expect(result.queuedFinal).toBe(false);
expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledTimes(3);
expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledOnce();
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined();
});
@@ -372,7 +336,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
try {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("durable reply", {
context: { channel: "whatsapp", to: "+1000" },
@@ -398,7 +361,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => pendingFinalReply("durable reply"),
replyResolver: async () => ({ text: "durable reply" }),
}),
});
await hookStarted.promise;
@@ -430,7 +393,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
try {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("durable reply"),
};
@@ -459,7 +421,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => [{ text: "first" }, pendingFinalReply("durable reply")],
replyResolver: async () => [{ text: "first" }, { text: "durable reply" }],
}),
});
await hookStarted.promise;
@@ -484,7 +446,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
try {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("durable reply"),
};
@@ -513,7 +474,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => [{ text: "auxiliary" }, pendingFinalReply("durable reply")],
replyResolver: async () => [{ text: "auxiliary" }, { text: "durable reply" }],
}),
});
await hookStarted.promise;
@@ -534,18 +495,62 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
}
});
it("records each pending-final delivery without rewriting aggregate text", async () => {
it("narrows combined retry text to finals that failed before transport", async () => {
vi.useFakeTimers();
try {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("auxiliary\n\ndurable reply", {
deliveries: [
{ id: "delivery-auxiliary", state: "prepared" },
{ id: "delivery-durable", state: "prepared" },
],
pendingFinalDelivery: pendingFinalDelivery("auxiliary\n\ndurable reply"),
};
sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({
existing: sessionStoreMocks.currentEntry,
});
const hookStarted = createDeferred();
let hookCalls = 0;
const dispatcher = createReplyDispatcher({
deliver: vi.fn().mockResolvedValue(undefined),
beforeDeliver: (payload) => {
hookCalls += 1;
if (hookCalls === 2) {
hookStarted.resolve();
return new Promise<never>(() => {});
}
return payload;
},
});
const resultPromise = withReplyDispatcher({
dispatcher,
run: () =>
dispatchReplyFromConfig({
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => [{ text: "auxiliary" }, { text: "durable reply" }],
}),
});
await hookStarted.promise;
await vi.advanceTimersByTimeAsync(15_000);
await resultPromise;
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toEqual(
pendingFinalDelivery("durable reply"),
);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("narrows heartbeat-normalized retry text using its originating payloads", async () => {
vi.useFakeTimers();
try {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("auxiliary durable reply", {
intentId: "heartbeat-intent",
}),
};
sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({
@@ -573,8 +578,20 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
cfg: emptyConfig,
dispatcher,
replyResolver: async () => [
pendingFinalReply("auxiliary", { deliveryId: "delivery-auxiliary" }),
pendingFinalReply("durable reply", { deliveryId: "delivery-durable" }),
setReplyPayloadMetadata(
{ text: "auxiliary" },
{
pendingFinalDeliveryIntentId: "heartbeat-intent",
pendingFinalDeliveryRetryText: "auxiliary",
},
),
setReplyPayloadMetadata(
{ text: "durable reply" },
{
pendingFinalDeliveryIntentId: "heartbeat-intent",
pendingFinalDeliveryRetryText: "durable reply",
},
),
],
}),
});
@@ -583,12 +600,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
await resultPromise;
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toEqual(
pendingFinalDelivery("auxiliary\n\ndurable reply", {
deliveries: [
{ id: "delivery-auxiliary", state: "delivered" },
{ id: "delivery-durable", state: "prepared" },
],
}),
pendingFinalDelivery("durable reply", { intentId: "heartbeat-intent" }),
);
expect(vi.getTimerCount()).toBe(0);
} finally {
@@ -601,7 +613,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
try {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("older reply", { intentId: "older-intent" }),
};
@@ -625,7 +636,10 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
cfg: emptyConfig,
dispatcher,
replyResolver: async () =>
pendingFinalReply("older reply", { intentId: "older-intent" }),
setReplyPayloadMetadata(
{ text: "older reply" },
{ pendingFinalDeliveryIntentId: "older-intent" },
),
}),
});
await hookStarted.promise;
@@ -674,13 +688,12 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
],
["nested partial envelope", new Error("partial", { cause: createPartialDelivery() }), false],
["aggregate partial envelope", new AggregateError([createPartialDelivery()]), false],
["observer-attached delivery evidence", createNoSendFailure(), true],
["observer-attached delivery evidence", createNoSendFailure(), false],
["ambiguous transport failure", new Error("transport failed"), false],
] as const)("reconciles pending final delivery after %s", async (name, error, preserve) => {
hookMocks.runner.hasHooks.mockReturnValue(false);
const pending = pendingFinalDelivery("recoverable final reply");
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pending,
};
@@ -704,19 +717,17 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => pendingFinalReply("recoverable final reply"),
replyResolver: async () => ({ text: "recoverable final reply" }),
}),
});
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toMatchObject({
...pending,
deliveries: [{ id: "delivery-1", state: preserve ? "prepared" : "unknown" }],
});
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toEqual(
preserve ? pending : undefined,
);
});
it("clears pending final delivery after intentional pre-delivery cancellation", async () => {
hookMocks.runner.hasHooks.mockReturnValue(false);
sessionStoreMocks.currentEntry = {
sessionId: "session-1",
sessionKey: "agent:test:session",
pendingFinalDelivery: pendingFinalDelivery("policy-suppressed reply"),
};
@@ -733,11 +744,11 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => pendingFinalReply("policy-suppressed reply"),
replyResolver: async () => ({ text: "policy-suppressed reply" }),
});
await dispatcher.waitForIdle();
await vi.waitFor(() => {
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined();
expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledOnce();
});
expect(result.queuedFinal).toBe(true);
@@ -746,7 +757,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
// does not trigger a fallback attempt.
expect(dispatcher.getCancelledCounts?.()).toEqual({ tool: 0, block: 0, final: 1 });
expect(dispatcher.getFailedCounts?.()).toEqual({ tool: 0, block: 0, final: 0 });
expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledTimes(2);
expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined();
});
it("delivers a generated final reply before queued follow-up admission", async () => {
@@ -4,10 +4,10 @@ import {
INTERNAL_RUNTIME_CONTEXT_BEGIN,
INTERNAL_RUNTIME_CONTEXT_END,
} from "../../agents/internal-runtime-context.js";
import { setReplyPayloadMetadata } from "../reply-payload.js";
import { markInboundContextLabel } from "./inbound-context-marker.js";
import {
buildRecoverablePendingFinalDeliveryText,
buildPendingFinalDeliveryText,
normalizePendingFinalDeliveryPayloads,
normalizePendingFinalRecoveryPayloads,
sanitizePendingFinalDeliveryText,
@@ -95,12 +95,12 @@ describe("normalizePendingFinalRecoveryPayloads", () => {
const rawPayloads = [{ text: "Rendered chart\nMEDIA:/tmp/chart.png" }];
const recoveryPayloads = normalizePendingFinalRecoveryPayloads(rawPayloads);
expect(recoveryPayloads.map((payload) => payload.text)).toEqual([
expect(buildPendingFinalDeliveryText(recoveryPayloads)).toBe(
"Rendered chart\nMEDIA:/tmp/chart.png",
]);
);
const deliveryPayloads = normalizePendingFinalDeliveryPayloads(rawPayloads);
expect(deliveryPayloads.map((payload) => payload.text)).toEqual(["Rendered chart"]);
expect(buildPendingFinalDeliveryText(deliveryPayloads)).toBe("Rendered chart");
});
it("keeps media-only directives as durable recovery text", () => {
@@ -108,7 +108,7 @@ describe("normalizePendingFinalRecoveryPayloads", () => {
{ text: "MEDIA:/tmp/chart.png" },
]);
expect(recoveryPayloads.map((payload) => payload.text)).toEqual(["MEDIA:/tmp/chart.png"]);
expect(buildPendingFinalDeliveryText(recoveryPayloads)).toBe("MEDIA:/tmp/chart.png");
expect(normalizePendingFinalDeliveryPayloads(recoveryPayloads)).toHaveLength(1);
});
@@ -139,18 +139,6 @@ describe("normalizePendingFinalRecoveryPayloads", () => {
).toBeUndefined();
});
it("separates implicit delivery threading from explicit reply semantics", () => {
expect(
buildRecoverablePendingFinalDeliveryText([
{ text: "Visible final", replyToId: "source-message" },
]),
).toBe("Visible final");
const explicitReply = { text: "Visible final", replyToId: "source-message" };
setReplyPayloadMetadata(explicitReply, { replyToIdExplicit: true });
expect(buildRecoverablePendingFinalDeliveryText([explicitReply])).toBeUndefined();
});
it("refuses multi-payload media finals because text recovery loses payload boundaries", () => {
expect(
buildRecoverablePendingFinalDeliveryText([
+6 -20
View File
@@ -1,7 +1,5 @@
import type { SessionEntry } from "../../config/sessions/types.js";
import type { DurableDeliveryCompletion } from "../../infra/outbound/delivery-completion.js";
import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js";
import { getReplyPayloadMetadata, type ReplyPayload } from "../reply-payload.js";
import {
isSilentReplyPayloadText,
isSilentReplyText,
@@ -10,6 +8,7 @@ import {
stripLeadingSilentToken,
stripSilentToken,
} from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import { stripInternalMetadataForDisplay } from "./display-text-sanitize.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
@@ -39,16 +38,12 @@ export function buildRecoverablePendingFinalDeliveryText(
if (payload.isReasoning === true) {
continue;
}
const recoveryPayload =
payload.replyToId && getReplyPayloadMetadata(payload)?.replyToIdExplicit !== true
? { ...payload, replyToId: undefined }
: payload;
const deliveryPayloads = normalizeReplyPayloadsForDelivery([recoveryPayload]);
const deliveryPayloads = normalizeReplyPayloadsForDelivery([payload]);
if (deliveryPayloads.length === 0) {
continue;
}
if (
hasUnsupportedDurableRecoveryShape(recoveryPayload) ||
hasUnsupportedDurableRecoveryShape(payload) ||
deliveryPayloads.some(hasUnrecoverableNormalizedDeliveryShape)
) {
return undefined;
@@ -83,7 +78,7 @@ export function buildRecoverablePendingFinalDeliveryText(
}
/** Build the restart-recovery text represented by one or more final payloads. */
function buildPendingFinalDeliveryText(payloads: ReplyPayload[]): string {
export function buildPendingFinalDeliveryText(payloads: ReplyPayload[]): string {
const text = payloads
.filter((payload) => payload.isReasoning !== true)
.map((payload) => payload.text)
@@ -98,15 +93,6 @@ export const PENDING_FINAL_DELIVERY_CLEAR_PATCH = {
pendingFinalDelivery: undefined,
} as const satisfies Partial<SessionEntry>;
export function resolvePendingFinalDeliveryCompletion(
payloads: readonly ReplyPayload[] | undefined,
): Extract<DurableDeliveryCompletion, { kind: "pending-final" }> | undefined {
const completion = payloads
?.map((payload) => getReplyPayloadMetadata(payload)?.pendingFinalDeliveryCompletion)
.find(Boolean);
return completion ? { kind: "pending-final", ...completion } : undefined;
}
function collectDurableMediaDirectives(payload: ReplyPayload): string[] {
if (payload.sensitiveMedia === true) {
return [];
@@ -136,8 +122,8 @@ function hasUnsupportedDurableRecoveryShape(payload: ReplyPayload): boolean {
payload.channelData !== undefined ||
payload.location !== undefined ||
payload.replyToId !== undefined ||
payload.replyToTag === true ||
payload.replyToCurrent === true ||
payload.replyToTag !== undefined ||
payload.replyToCurrent !== undefined ||
payload.audioAsVoice === true ||
payload.videoAsNote === true ||
payload.spokenText !== undefined ||
+3 -5
View File
@@ -467,14 +467,12 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
await options.deliver(deliverPayload, info);
return "delivered";
} catch (error) {
const outcome =
deliveryStarted && !isRetryableNoSendFailure(error)
? "failed-deliver"
: "failed-before-deliver";
try {
await options.onError?.(error, info);
} catch {}
return outcome;
return deliveryStarted && !isRetryableNoSendFailure(error)
? "failed-deliver"
: "failed-before-deliver";
}
};
@@ -13,10 +13,6 @@ export type ReplyFollowupAdmissionBarrierTimeoutPolicy = {
export type ReplyDispatchRuntimeInfo = {
kind: ReplyDispatchKind;
assistantMessageIndex?: number;
/** @internal Claim direct-send custody immediately before recipient-visible platform I/O. */
onPlatformSendDispatch?: () => Promise<void>;
/** @internal Bind this delivery's host-owned completion to a transformed payload. */
bindPendingFinalDelivery?: <T extends ReplyPayload>(payload: T) => T;
};
export type ReplyDispatchBeforeDeliver = (
@@ -29,7 +29,6 @@ type ReplyRestartRecoveryClaimController = {
state: Exclude<RestartRecoveryBeforeAgentReplyState, "admitted" | "pending">;
pendingFinalDelivery?: {
context?: DeliveryContext;
deliveries: NonNullable<SessionEntry["pendingFinalDelivery"]>["deliveries"];
intentId: string;
text: string;
};
@@ -377,7 +376,6 @@ export function createReplyRestartRecoveryClaimController(params: {
...(pendingFinalDelivery.intentId
? { intentId: pendingFinalDelivery.intentId }
: {}),
deliveries: pendingFinalDelivery.deliveries,
...(pendingFinalDelivery.context
? { context: pendingFinalDelivery.context }
: {}),
+10 -24
View File
@@ -4,7 +4,6 @@
* Sends rendered reply payloads, records live preview state, and classifies delivery outcomes.
*/
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import { resolvePendingFinalDeliveryCompletion } from "../../auto-reply/reply/pending-final-delivery.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { OutboundDeliveryResult } from "../../infra/outbound/deliver-types.js";
import {
@@ -396,27 +395,14 @@ export async function withDurableMessageSendContext<T>(
export async function sendDurableMessageBatch(
params: DurableMessageSendContextParams,
): Promise<DurableMessageBatchSendResult> {
const pendingFinalCompletion = params.deliveryCompletion
? undefined
: resolvePendingFinalDeliveryCompletion(params.payloads);
const pendingFinalDelivery = pendingFinalCompletion
? {
deliveryCompletion: pendingFinalCompletion,
deliveryIntentId: pendingFinalCompletion.deliveryId,
durability: "required" as const,
}
: {};
return await withDurableMessageSendContext(
{ ...params, ...pendingFinalDelivery },
async (ctx) => {
const rendered = await ctx.render();
const result = await ctx.send(rendered);
if (result.status === "sent" || result.status === "suppressed") {
await ctx.commit(result.receipt);
} else {
await ctx.fail(result.error);
}
return result;
},
);
return await withDurableMessageSendContext(params, async (ctx) => {
const rendered = await ctx.render();
const result = await ctx.send(rendered);
if (result.status === "sent" || result.status === "suppressed") {
await ctx.commit(result.receipt);
} else {
await ctx.fail(result.error);
}
return result;
});
}
-2
View File
@@ -832,8 +832,6 @@ export type ChannelPollContext = {
silent?: boolean;
isAnonymous?: boolean;
gatewayClientScopes?: readonly string[];
/** @internal Refresh durable timing before recipient-visible platform I/O. */
onPlatformSendDispatch?: () => Promise<void>;
};
/** Minimal base for all channel probe results. Channel-specific probes extend this. */
@@ -1,58 +0,0 @@
import {
getReplyPayloadMetadata,
setReplyPayloadMetadata,
type ReplyPayload,
} from "../../auto-reply/reply-payload.js";
import { PlatformMessageNotDispatchedError } from "../../infra/outbound/deliver-types.js";
import { settlePendingFinalDelivery } from "../../infra/outbound/delivery-completion.js";
import type { ChannelDeliveryInfo } from "./types.js";
type DirectPendingFinalCustody = Pick<ChannelDeliveryInfo, "bindPendingFinalDelivery"> & {
onPlatformSendDispatch: () => Promise<void>;
};
export const NO_PENDING_FINAL_CUSTODY: DirectPendingFinalCustody = {
onPlatformSendDispatch: () => Promise.resolve(),
};
export function resolvePendingFinalCompletion(payload: ReplyPayload) {
const identity = getReplyPayloadMetadata(payload)?.pendingFinalDeliveryCompletion;
return identity ? { kind: "pending-final" as const, ...identity } : undefined;
}
export function createDirectPendingFinalCustody(
payload: ReplyPayload,
): DirectPendingFinalCustody | undefined {
const completion = resolvePendingFinalCompletion(payload);
if (!completion) {
return undefined;
}
const { kind: _kind, ...identity } = completion;
let admission: Promise<void> | undefined;
return {
bindPendingFinalDelivery: (nextPayload) =>
setReplyPayloadMetadata(nextPayload, {
pendingFinalDeliveryCompletion: identity,
}),
onPlatformSendDispatch: () => {
admission ??= settlePendingFinalDelivery(completion, "unknown", "prepared").then((result) => {
if (result.state !== "unknown") {
throw new PlatformMessageNotDispatchedError(
"Pending final delivery ownership changed before platform dispatch",
{ cause: new Error(`pending final delivery is ${result.state}`) },
);
}
});
return admission;
},
};
}
export function toCoreManagedDeliveryInfo(info: ChannelDeliveryInfo) {
return {
kind: info.kind,
...(info.assistantMessageIndex === undefined
? {}
: { assistantMessageIndex: info.assistantMessageIndex }),
};
}
+2 -3
View File
@@ -204,6 +204,7 @@ export async function deliverInboundReplyWithMessageSendContext(
requesterSenderUsername: params.ctxPayload.SenderUsername,
requesterSenderE164: params.ctxPayload.SenderE164,
});
const send = await sendDurableMessageBatch({
cfg: params.cfg,
channel,
@@ -219,9 +220,7 @@ export async function deliverInboundReplyWithMessageSendContext(
mediaAccess: params.mediaAccess,
silent: params.silent,
durability,
...(requiredCapabilities.reconcileUnknownSend === true
? { requireUnknownSendReconciliation: true }
: {}),
...(durability === "required" ? { requireUnknownSendReconciliation: true } : {}),
session,
gatewayClientScopes: params.ctxPayload.GatewayClientScopes ?? [],
});
+18 -57
View File
@@ -1,6 +1,5 @@
import { dispatchInboundMessageWithRoutedChannelDispatcher } from "../../auto-reply/dispatch.js";
import { copyReplyPayloadMetadata, type ReplyPayload } from "../../auto-reply/reply-payload.js";
import { suppressPendingFinalDelivery } from "../../auto-reply/reply/dispatch-from-config.pending-final.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { DispatchFromConfigResult } from "../../auto-reply/reply/dispatch-from-config.types.js";
import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js";
import { runWithSessionInitConflictRetry } from "../../auto-reply/reply/session-init-conflict-retry.js";
@@ -13,7 +12,6 @@ import { formatErrorMessage, toErrorObject } from "../../infra/errors.js";
import { applyMessageSendingHook } from "../../infra/outbound/deliver-hooks.js";
import { normalizeEmptyPayloadForDelivery } from "../../infra/outbound/deliver-payload.js";
import { isPlatformMessageNotDispatchedError } from "../../infra/outbound/deliver-types.js";
import { settlePendingFinalDelivery } from "../../infra/outbound/delivery-completion.js";
import { createMessageSentEmitter } from "../../infra/outbound/message-sent-hook.js";
import { summarizeOutboundPayloadForTransport } from "../../infra/outbound/payloads.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
@@ -21,12 +19,6 @@ import { resolveMessageReceiptPrimaryId } from "../message/receipt.js";
import { createChannelReplyPipeline } from "../message/reply-pipeline.js";
import { recordInboundSession } from "../session.js";
import { isChannelPartialDeliveryError } from "./delivery-result.js";
import {
createDirectPendingFinalCustody,
NO_PENDING_FINAL_CUSTODY,
resolvePendingFinalCompletion,
toCoreManagedDeliveryInfo,
} from "./direct-delivery-custody.js";
import {
deliverInboundReplyWithMessageSendContext,
isDurableInboundReplyDeliveryHandled,
@@ -274,13 +266,6 @@ async function settleChannelDeliveryAttempt(params: {
messageId: resolveChannelDeliveryMessageId(finalized),
});
}
const completion = resolvePendingFinalCompletion(attempt.payload);
if (completion) {
await settlePendingFinalDelivery(
completion,
isExplicitlyNonVisibleChannelDelivery(finalized) ? "suppressed" : "delivered",
);
}
await runChannelDeliveryObserver({
onDelivered: params.onDelivered,
payload: attempt.payload,
@@ -347,7 +332,7 @@ async function applyRoutedDirectMessageSending(params: {
}),
};
}
return { payload: copyReplyPayloadMetadata(params.payload, payload) };
return { payload };
}
function reconcileNonVisibleChannelDeliveries(
@@ -480,7 +465,6 @@ async function dispatchChannelTurnWithDeliveryOwner(
| "cancelled_by_reply_payload_sending_hook"
| "empty_after_reply_payload_sending_hook",
) => {
await suppressPendingFinalDelivery(payload);
await runChannelDeliveryObserver({
onDelivered: delivery.onDelivered,
payload,
@@ -493,18 +477,13 @@ async function dispatchChannelTurnWithDeliveryOwner(
dispatcherOptions: {
...replyPipeline.dispatcherOptions,
deliver: async (payload: ReplyPayload, info: ChannelDeliveryInfo) => {
const preparedPayloadResult = delivery.preparePayload
const preparedPayload = delivery.preparePayload
? await delivery.preparePayload(payload, info)
: payload;
const preparedPayload =
preparedPayloadResult === null
? null
: copyReplyPayloadMetadata(payload, preparedPayloadResult);
if (preparedPayload === null) {
const suppression = createSuppressedChannelDeliveryResult({
reason: "no_visible_payload",
});
await suppressPendingFinalDelivery(payload);
await runChannelDeliveryObserver({
onDelivered: delivery.onDelivered,
payload,
@@ -546,22 +525,15 @@ async function dispatchChannelTurnWithDeliveryOwner(
}
let effectivePayload = preparedPayload;
let result: ChannelDeliveryResult | void = undefined;
let directInfo: ChannelDeliveryInfo = info;
try {
if (
ownership === "routed-delivery" &&
"deliverWithProviderMessageSending" in delivery &&
delivery.deliverWithProviderMessageSending
) {
const providerInfo = {
...info,
...(createDirectPendingFinalCustody(effectivePayload) ??
NO_PENDING_FINAL_CUSTODY),
};
directInfo = providerInfo;
result = await delivery.deliverWithProviderMessageSending(
effectivePayload,
providerInfo,
info,
);
} else {
if (
@@ -583,22 +555,13 @@ async function dispatchChannelTurnWithDeliveryOwner(
"channel delivery adapter is missing a direct deliverer",
);
}
const custody = createDirectPendingFinalCustody(effectivePayload);
await custody?.onPlatformSendDispatch();
result = await delivery.deliver(
effectivePayload,
toCoreManagedDeliveryInfo(info),
);
result = await delivery.deliver(effectivePayload, info);
}
}
} catch (error: unknown) {
if (delivery.observeMessageSent) {
await settleChannelDeliveryAttempt({
attempt: {
payload: effectivePayload,
info: directInfo,
error,
},
attempt: { payload: effectivePayload, info, error },
onDelivered: delivery.onDelivered,
emitMessageSent: getMessageSentEmitter()?.emitMessageSent,
});
@@ -609,24 +572,22 @@ async function dispatchChannelTurnWithDeliveryOwner(
// Finalization can reject while the buffered dispatcher is still unwinding.
// Observe it now; settlement still awaits the original promise and its error.
void result.finalization.catch(() => undefined);
pendingDeliveryAttempts.push({
payload: effectivePayload,
info: directInfo,
result,
});
} else {
pendingDeliveryAttempts.push({ payload: effectivePayload, info, result });
} else if (delivery.observeMessageSent) {
const finalized = await settleChannelDeliveryAttempt({
attempt: {
payload: effectivePayload,
info: directInfo,
result,
},
attempt: { payload: effectivePayload, info, result },
onDelivered: delivery.onDelivered,
emitMessageSent: delivery.observeMessageSent
? getMessageSentEmitter()?.emitMessageSent
: undefined,
emitMessageSent: getMessageSentEmitter()?.emitMessageSent,
});
recordSettledDelivery(info, finalized);
} else {
await runChannelDeliveryObserver({
onDelivered: delivery.onDelivered,
payload: effectivePayload,
info,
result,
});
recordSettledDelivery(info, result ?? undefined);
}
return result;
},
@@ -1,10 +1,6 @@
// Channel turn delivery tests cover orchestration, dispatch, and completion behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getReplyPayloadMetadata,
setReplyPayloadMetadata,
type ReplyPayload,
} from "../../auto-reply/reply-payload.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -14,7 +10,7 @@ import { outboundMessageIdentities } from "../message/outbound-echo-state.js";
import type { RecordInboundSession } from "../session.types.js";
import { hasVisibleChannelTurnDispatch } from "./dispatch-result.js";
import { dispatchAssembledChannelTurn, dispatchRoutedChannelTurn } from "./lifecycle.js";
import type { ChannelDeliveryInfo, ChannelTurnResult } from "./types.js";
import type { ChannelTurnResult } from "./types.js";
const deliverOutboundPayloads = vi.hoisted(() => vi.fn());
const resolveOutboundDurableFinalDeliverySupport = vi.hoisted(() => vi.fn());
@@ -28,9 +24,6 @@ const createMessageSentEmitter = vi.hoisted(() =>
vi.fn(() => ({ emitMessageSent, hasMessageSentHooks: true })),
);
const readRecentUserAssistantTextForSession = vi.hoisted(() => vi.fn());
const settlePendingFinalDelivery = vi.hoisted(() =>
vi.fn(async (_completion: unknown, state: string) => ({ state })),
);
vi.mock("../../auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
const actual =
@@ -84,12 +77,6 @@ vi.mock("../../config/sessions/transcript.js", () => ({
readRecentUserAssistantTextForSession,
}));
vi.mock("../../infra/outbound/delivery-completion.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/delivery-completion.js")>();
return { ...actual, settlePendingFinalDelivery };
});
const cfg = {} as OpenClawConfig;
function createCtx(overrides: Partial<FinalizedMsgContext> = {}): FinalizedMsgContext {
@@ -299,77 +286,6 @@ describe("channel turn delivery", () => {
expect(result.dispatchResult.counts.final).toBe(1);
});
it("preserves pending final custody through preparation and message hook rewrites", async () => {
const order: string[] = [];
const completion = {
deliveryId: "delivery-1",
intentId: "intent-1",
sessionId: "session-1",
sessionKey: "agent:main:telegram:peer",
storePath: "/tmp/sessions.json",
};
const sourcePayload = setReplyPayloadMetadata(
{ text: "reply" },
{ pendingFinalDeliveryCompletion: completion },
);
dispatchReplyWithRoutedChannelDispatcherCore.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver(sourcePayload, { kind: "final" });
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
});
getGlobalHookRunner.mockReturnValue({
hasHooks: (name: string) => name === "message_sending",
runMessageSending: vi.fn(async ({ content }: { content: string }) => ({
content: `${content} + hook`,
})),
});
let releaseDelivery: (() => void) | undefined;
const deliveryPending = new Promise<void>((resolve) => {
releaseDelivery = resolve;
});
settlePendingFinalDelivery.mockImplementationOnce(async (_completion, state: string) => {
order.push(`settle:${state}`);
return { state };
});
const deliver = vi.fn(async (payload: ReplyPayload, info: ChannelDeliveryInfo) => {
expect(getReplyPayloadMetadata(payload)?.pendingFinalDeliveryCompletion).toEqual(completion);
expect("onPlatformSendDispatch" in info).toBe(false);
order.push("signal:accepted");
await deliveryPending;
return { messageIds: ["direct-1"], visibleReplySent: true };
});
const dispatch = dispatchRoutedChannelTurn({
cfg,
channel: "telegram",
accountId: "acct",
route: { agentId: "main", sessionKey: completion.sessionKey },
ctxPayload: createCtx({ Surface: "telegram", OriginatingTo: "chat-1" }),
delivery: {
preparePayload: (payload) => ({ ...payload, text: `${payload.text} + prepared` }),
deliver,
},
});
await vi.waitFor(() => expect(deliver).toHaveBeenCalledOnce());
expect(order).toEqual(["settle:unknown", "signal:accepted"]);
releaseDelivery?.();
await dispatch;
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0]?.[0]).toMatchObject({ text: "reply + prepared + hook" });
expect(settlePendingFinalDelivery).toHaveBeenNthCalledWith(
1,
{ kind: "pending-final", ...completion },
"unknown",
"prepared",
);
expect(settlePendingFinalDelivery).toHaveBeenNthCalledWith(
2,
{ kind: "pending-final", ...completion },
"delivered",
);
});
it("does not let message hooks resurrect payloads suppressed during preparation", async () => {
const runMessageSending = vi.fn(async () => ({ content: "resurrected" }));
getGlobalHookRunner.mockReturnValue({
@@ -538,10 +454,7 @@ describe("channel turn delivery", () => {
expect(deliverWithProviderMessageSending).toHaveBeenCalledWith(
{ text: "reply" },
expect.objectContaining({
kind: "final",
onPlatformSendDispatch: expect.any(Function),
}),
{ kind: "final" },
);
expect(runMessageSending).not.toHaveBeenCalled();
});
+5 -12
View File
@@ -9,7 +9,7 @@ import type { GetReplyFromConfig } from "../../auto-reply/reply/get-reply.types.
import type { HistoryEntry, HistoryMediaEntry } from "../../auto-reply/reply/history.types.js";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
import type { ReplyDispatcherWithTypingOptions } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyDispatchRuntimeInfo } from "../../auto-reply/reply/reply-dispatcher.types.js";
import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js";
import type {
FinalizedMsgContext,
InboundSourceModality,
@@ -155,15 +155,8 @@ export type PreflightFacts = {
};
/** Delivery metadata for one reply payload dispatch. */
export type ChannelDeliveryInfo = ReplyDispatchRuntimeInfo;
type ChannelCoreManagedDeliveryInfo = Omit<
ChannelDeliveryInfo,
"bindPendingFinalDelivery" | "onPlatformSendDispatch"
>;
type ChannelProviderOwnedDeliveryInfo = ChannelDeliveryInfo & {
onPlatformSendDispatch: () => Promise<void>;
export type ChannelDeliveryInfo = {
kind: ReplyDispatchKind;
};
/** Durable delivery queue intent recorded when a reply is deferred. */
@@ -226,7 +219,7 @@ type ChannelDeliveryAdapterBase = {
export type ChannelCoreManagedTurnDeliveryAdapter = ChannelDeliveryAdapterBase & {
deliver: (
payload: ReplyPayload,
info: ChannelCoreManagedDeliveryInfo,
info: ChannelDeliveryInfo,
) => Promise<ChannelDeliveryResult | void>;
durable?:
| false
@@ -251,7 +244,7 @@ export type ChannelProviderOwnedMessageSendingDeliveryAdapter = ChannelDeliveryA
*/
deliverWithProviderMessageSending: (
payload: ReplyPayload,
info: ChannelProviderOwnedDeliveryInfo,
info: ChannelDeliveryInfo,
) => Promise<ChannelDeliveryResult | void>;
deliver?: never;
durable?: never;
-1
View File
@@ -86,7 +86,6 @@ vi.mock("../agents/command/session-store.runtime.js", async () => {
const accessor = await import("../config/sessions/session-accessor.js");
return {
loadSessionEntry: accessor.loadSessionEntry,
loadSessionEntryReadOnly: accessor.loadSessionEntryReadOnly,
updateSessionStoreAfterAgentRun: vi.fn(async () => undefined),
};
});
-29
View File
@@ -81,35 +81,6 @@ it("normalizes boolean-only pending delivery as transport-only", () => {
});
});
it("normalizes exact pending-final delivery owners", () => {
expect(
normalizePersistedSessionEntryShape({
sessionId: "session-1",
updatedAt: 42,
pendingFinalDelivery: {
kind: "replayable",
text: "durable reply",
createdAt: 41,
intentId: "intent-1",
deliveries: [
{ id: "delivery-prepared", state: "prepared" },
{ id: "delivery-delivered", state: "delivered" },
{ id: "", state: "queued" },
{ id: "delivery-invalid", state: "invalid" },
],
},
}),
).toMatchObject({
pendingFinalDelivery: {
intentId: "intent-1",
deliveries: [
{ id: "delivery-prepared", state: "prepared" },
{ id: "delivery-delivered", state: "delivered" },
],
},
});
});
it("normalizes and preserves the durable assistant transcript repair backlog", () => {
expect(
normalizePersistedSessionEntryShape({
-20
View File
@@ -144,30 +144,10 @@ function normalizePendingFinalDelivery(
return undefined;
}
const intentId = normalizeOptionalString(value.intentId);
const deliveries: NonNullable<SessionEntry["pendingFinalDelivery"]>["deliveries"] = Array.isArray(
value.deliveries,
)
? value.deliveries.flatMap((delivery) => {
if (!isRecord(delivery)) {
return [];
}
const id = normalizeOptionalString(delivery.id);
const state = delivery.state;
return id &&
(state === "prepared" ||
state === "queued" ||
state === "delivered" ||
state === "suppressed" ||
state === "unknown")
? [{ id, state }]
: [];
})
: undefined;
const base = {
createdAt,
...(isRecord(value.context) ? { context: value.context } : {}),
...(intentId ? { intentId } : {}),
...(deliveries ? { deliveries } : {}),
};
if (value.kind === "transport-only") {
return { kind: "transport-only", ...base };
-4
View File
@@ -66,10 +66,6 @@ type PendingFinalDeliveryState = {
createdAt: number;
context?: DeliveryContext;
intentId?: string;
deliveries?: Array<{
id: string;
state: "prepared" | "queued" | "delivered" | "suppressed" | "unknown";
}>;
} & ({ kind: "replayable"; text: string } | { kind: "transport-only" });
/**
+28 -58
View File
@@ -103,38 +103,20 @@ function scopeChannelHandler(
) as ChannelHandler;
}
async function runChannelPlatformSend<
TContext extends { onPlatformSendDispatch?: () => Promise<void> },
TResult,
>(params: {
ctx: TContext;
beforePlatformSend?: (ctx: TContext) => Promise<void> | undefined;
send: (ctx: TContext) => Promise<TResult>;
}): Promise<TResult> {
await params.beforePlatformSend?.(params.ctx);
if (!params.ctx.onPlatformSendDispatch) {
return await params.send(params.ctx);
}
await params.ctx.onPlatformSendDispatch();
return await params.send({ ...params.ctx, onPlatformSendDispatch: undefined });
}
async function runChannelMessageSendWithLifecycle<
TContext extends ChannelMessageSendAttemptContext,
TResult extends ChannelMessageSendResult,
>(params: {
lifecycle?: ChannelMessageSendLifecycleAdapter;
ctx: TContext;
beforePlatformSend?: (ctx: TContext) => Promise<void> | undefined;
send: (ctx: TContext) => Promise<TResult>;
ctx: ChannelMessageSendAttemptContext;
send: () => Promise<TResult>;
}): Promise<{ result: TResult; afterCommit?: OutboundDeliveryCommitHook }> {
if (!params.lifecycle) {
return { result: await runChannelPlatformSend(params) };
return { result: await params.send() };
}
let attemptToken: unknown;
try {
attemptToken = await params.lifecycle.beforeSendAttempt?.(params.ctx);
const result = await runChannelPlatformSend(params);
const result = await params.send();
const successCtx = {
...params.ctx,
result,
@@ -422,19 +404,18 @@ function createPluginHandler(
const sent = await runChannelMessageSendWithLifecycle({
lifecycle: messageLifecycle,
ctx: messagePayloadCtx,
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => messagePayload(ctx),
send: async () => {
await params.onPlatformSendStart?.(messagePayloadCtx);
return await messagePayload(messagePayloadCtx);
},
});
return attachOutboundDeliveryCommitHook(
normalizeChannelMessageSendResult(params.channel, sent.result),
sent.afterCommit,
);
}
return await runChannelPlatformSend({
ctx: payloadCtx,
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => outbound!.sendPayload!(ctx),
});
await params.onPlatformSendStart?.(payloadCtx);
return outbound!.sendPayload!(payloadCtx);
}
: undefined,
sendFormattedText: outbound?.sendFormattedText
@@ -444,11 +425,8 @@ function createPluginHandler(
text,
};
assertUnknownSendReconciliationKind("text");
return await runChannelPlatformSend({
ctx: { ...formattedCtx, kind: "text" },
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => outbound.sendFormattedText!(ctx),
});
await params.onPlatformSendStart?.(formattedCtx);
return await outbound.sendFormattedText!(formattedCtx);
}
: undefined,
sendFormattedMedia: outbound?.sendFormattedMedia
@@ -459,11 +437,8 @@ function createPluginHandler(
mediaUrl,
};
assertUnknownSendReconciliationKind("media");
return await runChannelPlatformSend({
ctx: { ...formattedCtx, kind: "media" },
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => outbound.sendFormattedMedia!(ctx),
});
await params.onPlatformSendStart?.(formattedCtx);
return await outbound.sendFormattedMedia!(formattedCtx);
}
: undefined,
sendText: async (text, overrides) => {
@@ -478,19 +453,18 @@ function createPluginHandler(
const sent = await runChannelMessageSendWithLifecycle({
lifecycle: messageLifecycle,
ctx: messageTextCtx,
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => messageText(ctx),
send: async () => {
await params.onPlatformSendStart?.(messageTextCtx);
return await messageText(messageTextCtx);
},
});
return attachOutboundDeliveryCommitHook(
normalizeChannelMessageSendResult(params.channel, sent.result),
sent.afterCommit,
);
}
return await runChannelPlatformSend({
ctx: textCtx,
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => sendText!(ctx),
});
await params.onPlatformSendStart?.(textCtx);
return sendText!(textCtx);
},
buildTargetRef,
sendMedia: async (caption, mediaUrl, overrides) => {
@@ -506,8 +480,10 @@ function createPluginHandler(
const sent = await runChannelMessageSendWithLifecycle({
lifecycle: messageLifecycle,
ctx: messageMediaCtx,
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => messageMedia(ctx),
send: async () => {
await params.onPlatformSendStart?.(messageMediaCtx);
return await messageMedia(messageMediaCtx);
},
});
return attachOutboundDeliveryCommitHook(
normalizeChannelMessageSendResult(params.channel, sent.result),
@@ -515,17 +491,11 @@ function createPluginHandler(
);
}
if (sendMedia) {
return await runChannelPlatformSend({
ctx: mediaCtx,
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => sendMedia(ctx),
});
await params.onPlatformSendStart?.(mediaCtx);
return sendMedia(mediaCtx);
}
return await runChannelPlatformSend({
ctx: mediaCtx,
beforePlatformSend: params.onPlatformSendStart,
send: (ctx) => sendText!(ctx),
});
await params.onPlatformSendStart?.(mediaCtx);
return sendText!(mediaCtx);
},
};
}
+5 -17
View File
@@ -301,13 +301,9 @@ export async function deliverOutboundPayloadsWithQueueCleanup(
if (!queueId) {
if (params.deliveryCompletion) {
if (results.length > 0) {
await completeDurableDelivery(
params.deliveryCompletion,
results.at(-1)!,
platformQueueStateDir,
);
completeDurableDelivery(params.deliveryCompletion, results.at(-1)!);
} else {
await suppressDurableDelivery(params.deliveryCompletion, platformQueueStateDir);
suppressDurableDelivery(params.deliveryCompletion);
}
}
if (!params.deferCommitHooks) {
@@ -365,13 +361,9 @@ export async function deliverOutboundPayloadsWithQueueCleanup(
} else {
if (params.deliveryCompletion) {
if (results.length > 0) {
await completeDurableDelivery(
params.deliveryCompletion,
results.at(-1)!,
platformQueueStateDir,
);
completeDurableDelivery(params.deliveryCompletion, results.at(-1)!);
} else {
await suppressDurableDelivery(params.deliveryCompletion, platformQueueStateDir);
suppressDurableDelivery(params.deliveryCompletion);
}
}
const postSendState =
@@ -568,11 +560,7 @@ export async function deliverOutboundPayloadsWithQueueCleanup(
terminalRejectionHandled = true;
} else {
if (params.deliveryCompletion) {
await rejectDurableDelivery(
params.deliveryCompletion,
permanentRejection.message,
platformQueueStateDir,
);
rejectDurableDelivery(params.deliveryCompletion, permanentRejection.message);
ownerRejected = true;
}
await (producerClaimId
-16
View File
@@ -14,9 +14,7 @@ import {
stageAndEnqueueOutboundDelivery,
} from "./deliver-queue-admission.js";
import { deliverOutboundPayloadsWithQueueCleanup } from "./deliver-queue-execute.js";
import { createQueuedDeliveryOwner } from "./deliver-queue-state.js";
import type { OutboundDeliveryResult } from "./deliver-types.js";
import { markDurableDeliveryQueued } from "./delivery-completion.js";
import { startDeliveryProducerLease } from "./delivery-queue-lease.js";
import {
StableDeliveryPreparationLostError,
@@ -274,20 +272,6 @@ async function runOutboundDeliveryWithQueue(
if (queued?.created && stablePreparationOwner) {
stablePreparationOwner.markPublished();
}
if (queueId && params.deliveryCompletion) {
const completion = await markDurableDeliveryQueued(
params.deliveryCompletion,
queueId,
queued?.created ? "prepared" : undefined,
);
if (completion.state !== "queued") {
await createQueuedDeliveryOwner({
queueId,
expectedPlatformSendAttemptId: queued?.producerClaimId,
}).ack({ suppressCompletionReceipt: true });
return [];
}
}
if (queueId) {
params.onDeliveryIntent?.({
id: queueId,
+5 -30
View File
@@ -96,7 +96,6 @@ const queueMocks = vi.hoisted(() => ({
}));
const completionMocks = vi.hoisted(() => ({
completeDurableDelivery: vi.fn(),
markDurableDeliveryQueued: vi.fn(async () => ({ state: "queued" as const })),
rejectDurableDelivery: vi.fn(),
suppressDurableDelivery: vi.fn(),
}));
@@ -208,7 +207,6 @@ vi.mock("./delivery-queue.js", () => ({
}));
vi.mock("./delivery-completion.js", () => ({
completeDurableDelivery: completionMocks.completeDurableDelivery,
markDurableDeliveryQueued: completionMocks.markDurableDeliveryQueued,
rejectDurableDelivery: completionMocks.rejectDurableDelivery,
suppressDurableDelivery: completionMocks.suppressDurableDelivery,
}));
@@ -523,7 +521,6 @@ describe("deliverOutboundPayloads", () => {
},
);
completionMocks.completeDurableDelivery.mockClear();
completionMocks.markDurableDeliveryQueued.mockClear();
completionMocks.rejectDurableDelivery.mockClear();
completionMocks.suppressDurableDelivery.mockClear();
queueMocks.ackDelivery.mockClear();
@@ -837,7 +834,7 @@ describe("deliverOutboundPayloads", () => {
});
const messageSendText = vi.fn(async (ctx: ChannelMessageSendTextContext) => {
order.push("send");
expect(ctx.onPlatformSendDispatch).toBeUndefined();
await ctx.onPlatformSendDispatch?.();
return {
messageId: "message-adapter-1",
receipt: createMessageReceiptFromOutboundResults({
@@ -888,8 +885,8 @@ describe("deliverOutboundPayloads", () => {
expect(order).toEqual([
"queue",
"before",
"dispatch",
"send",
"dispatch",
"after:pending-1:message-adapter-1",
"mark-unknown",
"complete",
@@ -935,26 +932,6 @@ describe("deliverOutboundPayloads", () => {
expect(results[0]?.messageId).toBe("message-adapter-1");
});
it("does not claim platform custody when message adapter preflight fails", async () => {
const messageSendText = vi.fn();
setMatrixMessageAdapter({
id: "matrix",
durableFinal: { capabilities: { text: true } },
send: {
lifecycle: {
beforeSendAttempt: () => {
throw new Error("preflight rejected");
},
},
text: messageSendText,
},
});
await expect(deliverMatrix({ queuePolicy: "required" })).rejects.toThrow("preflight rejected");
expect(queueMocks.markDeliveryPlatformSendDispatched).not.toHaveBeenCalled();
expect(messageSendText).not.toHaveBeenCalled();
});
it("does not cross platform I/O when a stable queue intent already exists", async () => {
hookMocks.runner.hasHooks.mockImplementation((name?: string) => name === "message_sending");
queueMocks.findDeliveryIntentOwner.mockReturnValue({
@@ -1086,7 +1063,6 @@ describe("deliverOutboundPayloads", () => {
expect(completionMocks.completeDurableDelivery).toHaveBeenCalledWith(
expect.objectContaining({ operationId: "operation-chunked" }),
expect.objectContaining({ messageId: "chunk-2" }),
undefined,
);
});
@@ -1177,7 +1153,8 @@ describe("deliverOutboundPayloads", () => {
queueMocks.markDeliveryPlatformSendDispatched.mockRejectedValueOnce(
new Error("dispatch state unavailable"),
);
const messageSendText = vi.fn(async () => {
const messageSendText = vi.fn(async (ctx: ChannelMessageSendTextContext) => {
await ctx.onPlatformSendDispatch?.();
return {
messageId: "message-adapter-1",
receipt: createMessageReceiptFromOutboundResults({
@@ -1197,7 +1174,7 @@ describe("deliverOutboundPayloads", () => {
queuePolicy: "best_effort",
}),
).rejects.toThrow("dispatch state unavailable");
expect(messageSendText).not.toHaveBeenCalled();
expect(messageSendText).toHaveBeenCalledOnce();
expect(logMocks.warn).not.toHaveBeenCalledWith(
expect.stringContaining("continuing best-effort send: dispatch state unavailable"),
);
@@ -2192,7 +2169,6 @@ describe("deliverOutboundPayloads", () => {
expect(completionMocks.rejectDurableDelivery).toHaveBeenCalledWith(
expect.objectContaining({ operationId: "operation-rejected" }),
"atomic message limit",
undefined,
);
expect(queueMocks.failDeliveryBeforePlatformSend).not.toHaveBeenCalled();
expect(queueMocks.failDelivery).not.toHaveBeenCalled();
@@ -2230,7 +2206,6 @@ describe("deliverOutboundPayloads", () => {
expect(completionMocks.rejectDurableDelivery).toHaveBeenCalledWith(
expect.objectContaining({ operationId: "operation-empty-rejection" }),
"Platform rejected the message before dispatch",
undefined,
);
});
@@ -1,91 +0,0 @@
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { matrixOutboundForQueueTest } from "./deliver.queue-integration.test-support.js";
import { loadPendingDeliveries } from "./delivery-queue-storage.js";
import { installDeliveryQueueTmpDirHooks } from "./delivery-queue.test-helpers.js";
let deliverOutboundPayloads: typeof import("./deliver.js").deliverOutboundPayloads;
describe("pending-final durable delivery completion", () => {
const fixtures = installDeliveryQueueTmpDirHooks();
let tmpDir: string;
beforeAll(async () => {
({ deliverOutboundPayloads } = await import("./deliver.js"));
});
beforeEach(() => {
tmpDir = fixtures.tmpDir();
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForQueueTest }),
},
]),
);
});
afterEach(() => {
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("suppresses a second stable caller after the exact pending final was delivered", async () => {
process.env.OPENCLAW_STATE_DIR = tmpDir;
const sessionKey = "agent:main:matrix:direct:123";
const storePath = path.join(tmpDir, "sessions.json");
const deliveryId = "pending-final-delivery-1";
const completion = {
kind: "pending-final" as const,
deliveryId,
intentId: "pending-final-intent-1",
sessionId: "session-1",
sessionKey,
storePath,
};
await replaceSessionEntry(
{ sessionKey, storePath },
{
sessionId: "session-1",
status: "running",
updatedAt: Date.now(),
pendingFinalDelivery: {
kind: "replayable",
text: "deliver once",
createdAt: Date.now(),
intentId: completion.intentId,
deliveries: [{ id: deliveryId, state: "prepared" }],
},
},
);
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "matrix-message-1" });
const params = {
cfg: {} as OpenClawConfig,
channel: "matrix" as const,
to: "!room:example",
payloads: [{ text: "deliver once" }],
deps: { matrix: sendMatrix },
queuePolicy: "required" as const,
deliveryIntentId: deliveryId,
deliveryCompletion: completion,
};
await expect(deliverOutboundPayloads(params)).resolves.toMatchObject([
{ messageId: "matrix-message-1" },
]);
expect(loadSessionEntry({ sessionKey, storePath })?.pendingFinalDelivery?.deliveries).toEqual([
{ id: deliveryId, state: "delivered" },
]);
await expect(deliverOutboundPayloads(params)).resolves.toEqual([]);
expect(sendMatrix).toHaveBeenCalledOnce();
expect(await loadPendingDeliveries(tmpDir)).toEqual([]);
});
});
@@ -1,127 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { commitMainSessionRecovery } from "../../agents/main-session-recovery-store.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import type { InternalSessionEntry } from "../../config/sessions/types.js";
import { settlePendingFinalDelivery } from "./delivery-completion.js";
const recoveryMocks = vi.hoisted(() => ({
scheduleMainSessionRecoveryPendingTarget: vi.fn(),
}));
vi.mock("../../agents/main-session-recovery-owner-release.js", () => recoveryMocks);
describe("pending-final delivery completion", () => {
let tmpDir: string;
let storePath: string;
const sessionKey = "agent:main:main";
const completion = {
kind: "pending-final" as const,
deliveryId: "delivery-1",
intentId: "intent-1",
sessionId: "session-1",
sessionKey,
storePath: "",
};
beforeEach(async () => {
vi.clearAllMocks();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-delivery-completion-"));
storePath = path.join(tmpDir, "sessions.json");
completion.storePath = storePath;
const entry: InternalSessionEntry = {
sessionId: completion.sessionId,
status: "running",
abortedLastRun: true,
updatedAt: Date.now(),
mainRestartRecovery: {
cycleId: "cycle-1",
revision: 1,
chargedAttempts: 1,
},
pendingFinalDelivery: {
kind: "replayable",
text: "durable final",
createdAt: Date.now(),
intentId: completion.intentId,
deliveries: [{ id: completion.deliveryId, state: "prepared" }],
},
};
await replaceSessionEntry({ sessionKey, storePath }, entry);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("invalidates an earlier recovery decision and wakes the exact session", async () => {
const observation = { sessionId: completion.sessionId, cycleId: "cycle-1", revision: 1 };
await expect(settlePendingFinalDelivery(completion, "delivered")).resolves.toEqual({
state: "delivered",
});
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
mainRestartRecovery: { revision: 2 },
pendingFinalDelivery: {
deliveries: [{ id: completion.deliveryId, state: "delivered" }],
},
});
expect(recoveryMocks.scheduleMainSessionRecoveryPendingTarget).toHaveBeenCalledWith({
sessionId: completion.sessionId,
sessionKey,
storePath,
});
await expect(
commitMainSessionRecovery({
command: { kind: "fail_recovery", now: Date.now(), observation },
requireWriteSuccess: true,
target: { sessionKey, storePath },
}),
).resolves.toMatchObject({ transition: { kind: "rejected", reason: "stale_revision" } });
});
it("records queue custody without waking recovery", async () => {
await expect(settlePendingFinalDelivery(completion, "queued")).resolves.toEqual({
state: "queued",
});
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
mainRestartRecovery: { revision: 2 },
pendingFinalDelivery: {
deliveries: [{ id: completion.deliveryId, state: "queued" }],
},
});
expect(recoveryMocks.scheduleMainSessionRecoveryPendingTarget).not.toHaveBeenCalled();
});
it("carries the custom queue root when a terminal sibling wakes recovery", async () => {
const entry = loadSessionEntry({ sessionKey, storePath })!;
await replaceSessionEntry(
{ sessionKey, storePath },
{
...entry,
pendingFinalDelivery: {
...entry.pendingFinalDelivery!,
deliveries: [
{ id: completion.deliveryId, state: "prepared" },
{ id: "delivery-2", state: "queued" },
],
},
},
);
await expect(
settlePendingFinalDelivery(completion, "delivered", undefined, tmpDir),
).resolves.toEqual({ state: "delivered" });
expect(recoveryMocks.scheduleMainSessionRecoveryPendingTarget).toHaveBeenCalledWith({
sessionId: completion.sessionId,
sessionKey,
stateDir: tmpDir,
storePath,
});
});
});
+33 -166
View File
@@ -7,207 +7,74 @@ import {
markConversationDeliveryUnknown,
type ConversationDeliveryRecord,
} from "../../config/sessions/conversation-delivery-store.js";
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
import type { InternalSessionEntry } from "../../config/sessions/types.js";
import type { OutboundDeliveryResult } from "./deliver-types.js";
/** Serializable owner callback for a durable queue entry. */
export type DurableDeliveryCompletion =
| {
kind: "conversation";
agentId: string;
operationId: string;
storePath?: string;
}
| {
kind: "pending-final";
deliveryId: string;
intentId: string;
sessionId: string;
sessionKey: string;
storePath: string;
};
type DurableDeliveryCompletionResult = {
state: "prepared" | "queued" | "delivered" | "suppressed" | "rejected" | "unknown" | "stale";
platformMessageId?: string;
rejectionError?: string;
export type DurableDeliveryCompletion = {
kind: "conversation";
agentId: string;
operationId: string;
storePath?: string;
};
function scopeForCompletion(
completion: Extract<DurableDeliveryCompletion, { kind: "conversation" }>,
) {
function scopeForCompletion(completion: DurableDeliveryCompletion) {
return {
agentId: completion.agentId,
...(completion.storePath ? { storePath: completion.storePath } : {}),
};
}
function conversationResult(record: ConversationDeliveryRecord): DurableDeliveryCompletionResult {
const delivered = record.status === "sent" || record.status === "replied";
return {
state: delivered
? "delivered"
: record.status === "suppressed" ||
record.status === "rejected" ||
record.status === "unknown"
? record.status
: "queued",
...(delivered && (record.platformMessageId || record.preparedMessageId)
? { platformMessageId: record.platformMessageId ?? record.preparedMessageId }
: {}),
...(record.status === "rejected" && record.rejectionError
? { rejectionError: record.rejectionError }
: {}),
};
}
export async function settlePendingFinalDelivery(
completion: Extract<DurableDeliveryCompletion, { kind: "pending-final" }>,
state: Exclude<DurableDeliveryCompletionResult["state"], "rejected" | "stale">,
expectedState?: "prepared" | "queued" | "unknown",
stateDir?: string,
): Promise<DurableDeliveryCompletionResult> {
let settled: DurableDeliveryCompletionResult["state"] = "stale";
let wakeRecovery = false;
await updateSessionEntry(
{ sessionKey: completion.sessionKey, storePath: completion.storePath },
(entry) => {
const internalEntry: InternalSessionEntry = entry;
if (
internalEntry.sessionId !== completion.sessionId ||
internalEntry.pendingFinalDelivery?.intentId !== completion.intentId
) {
return null;
}
const deliveries = internalEntry.pendingFinalDelivery.deliveries;
const index = deliveries?.findIndex(({ id }) => id === completion.deliveryId) ?? -1;
if (!deliveries || index < 0) {
return null;
}
const current = deliveries[index]!.state;
if (expectedState && current !== expectedState) {
return null;
}
const terminal =
current === "delivered" ||
current === "suppressed" ||
(current === "unknown" && state === "unknown");
settled = terminal ? current : state;
if (settled === current) {
return null;
}
wakeRecovery =
settled !== "queued" &&
internalEntry.status === "running" &&
internalEntry.abortedLastRun === true;
return {
...(internalEntry.mainRestartRecovery
? {
mainRestartRecovery: {
...internalEntry.mainRestartRecovery,
revision: internalEntry.mainRestartRecovery.revision + 1,
},
}
: {}),
pendingFinalDelivery: {
...internalEntry.pendingFinalDelivery,
deliveries: deliveries.with(index, { id: completion.deliveryId, state: settled }),
},
updatedAt: Date.now(),
};
},
{ skipMaintenance: true, takeCacheOwnership: true },
);
if (wakeRecovery) {
const { scheduleMainSessionRecoveryPendingTarget } =
await import("../../agents/main-session-recovery-owner-release.js");
scheduleMainSessionRecoveryPendingTarget({
sessionId: completion.sessionId,
sessionKey: completion.sessionKey,
...(stateDir !== undefined ? { stateDir } : {}),
storePath: completion.storePath,
});
}
return { state: settled };
}
function readPlatformMessageId(result: OutboundDeliveryResult): string | undefined {
const receiptId = result.receipt ? resolveMessageReceiptPrimaryId(result.receipt) : undefined;
return receiptId ?? (result.messageId.trim() || undefined);
}
/** Records queue ownership before either the live sender or recovery crosses platform I/O. */
export async function markDurableDeliveryQueued(
export function markDurableDeliveryQueued(
completion: DurableDeliveryCompletion,
queueId: string,
expectedPendingFinalState?: "prepared",
): Promise<DurableDeliveryCompletionResult> {
return completion.kind === "pending-final"
? await settlePendingFinalDelivery(completion, "queued", expectedPendingFinalState)
: conversationResult(
markConversationDeliveryQueued(
scopeForCompletion(completion),
completion.operationId,
queueId,
),
);
): ConversationDeliveryRecord {
return markConversationDeliveryQueued(
scopeForCompletion(completion),
completion.operationId,
queueId,
);
}
/** Finalizes owner state from identified platform evidence before queue acknowledgement. */
export async function completeDurableDelivery(
export function completeDurableDelivery(
completion: DurableDeliveryCompletion,
result: OutboundDeliveryResult,
stateDir?: string,
): Promise<DurableDeliveryCompletionResult> {
return completion.kind === "pending-final"
? await settlePendingFinalDelivery(completion, "delivered", undefined, stateDir)
: conversationResult(
markConversationDeliverySent(
scopeForCompletion(completion),
completion.operationId,
readPlatformMessageId(result),
),
);
): ConversationDeliveryRecord {
return markConversationDeliverySent(
scopeForCompletion(completion),
completion.operationId,
readPlatformMessageId(result),
);
}
/** Finalizes a policy-suppressed send before its durable intent is acknowledged. */
export async function suppressDurableDelivery(
export function suppressDurableDelivery(
completion: DurableDeliveryCompletion,
stateDir?: string,
): Promise<DurableDeliveryCompletionResult> {
return completion.kind === "pending-final"
? await settlePendingFinalDelivery(completion, "suppressed", undefined, stateDir)
: conversationResult(
markConversationDeliverySuppressed(scopeForCompletion(completion), completion.operationId),
);
): ConversationDeliveryRecord {
return markConversationDeliverySuppressed(scopeForCompletion(completion), completion.operationId);
}
/** Finalizes a permanent provider rejection that provably preceded platform I/O. */
export async function rejectDurableDelivery(
export function rejectDurableDelivery(
completion: DurableDeliveryCompletion,
error: string,
stateDir?: string,
): Promise<DurableDeliveryCompletionResult> {
return completion.kind === "pending-final"
? await settlePendingFinalDelivery(completion, "unknown", undefined, stateDir)
: conversationResult(
markConversationDeliveryRejected(
scopeForCompletion(completion),
completion.operationId,
error,
),
);
): ConversationDeliveryRecord {
return markConversationDeliveryRejected(
scopeForCompletion(completion),
completion.operationId,
error,
);
}
/** Makes a dead-lettered durable send terminal without allowing a blind replay. */
export async function failDurableDelivery(
export function failDurableDelivery(
completion: DurableDeliveryCompletion,
stateDir?: string,
): Promise<DurableDeliveryCompletionResult> {
return completion.kind === "pending-final"
? await settlePendingFinalDelivery(completion, "unknown", undefined, stateDir)
: conversationResult(
markConversationDeliveryUnknown(scopeForCompletion(completion), completion.operationId),
);
): ConversationDeliveryRecord {
return markConversationDeliveryUnknown(scopeForCompletion(completion), completion.operationId);
}
@@ -329,7 +329,6 @@ describe("outbound prepared queue migration", () => {
expect(hookMocks.runMessageSending).not.toHaveBeenCalled();
expect(completionMocks.failDurableDelivery).toHaveBeenCalledWith(
interrupted.deliveryCompletion,
tmpDir(),
);
expect(getDeliveryQueueEntryStatus(OUTBOUND_LEGACY_PREPARATION_QUEUE_NAME, id, tmpDir())).toBe(
"failed",
@@ -320,7 +320,7 @@ async function failInterruptedLegacyPreparation(params: {
}
if (params.entry.deliveryCompletion) {
try {
await failDurableDelivery(params.entry.deliveryCompletion, params.stateDir);
failDurableDelivery(params.entry.deliveryCompletion);
} catch (error) {
params.log.warn(
`Legacy delivery ${params.entry.id} interrupted preparation owner could not be marked unknown: ${String(error)}`,
+30 -36
View File
@@ -355,7 +355,7 @@ async function applyRecoveryDeliveryAdmission(params: {
if (admission.status === "allowed") {
return "allowed";
}
await markDurableDeliveryFailedBestEffort(params.entry, params.log, params.stateDir);
markDurableDeliveryFailedBestEffort(params.entry, params.log);
const result = await failPendingDelivery(
{
id: params.entry.id,
@@ -547,7 +547,7 @@ async function moveEntryToFailedWithLogging(
log: RecoveryLogger,
stateDir?: string,
): Promise<boolean> {
await markDurableDeliveryFailedBestEffort(entry, log, stateDir);
markDurableDeliveryFailedBestEffort(entry, log);
try {
const attemptId = recoveryPlatformAttemptId(entry);
await moveEntryToFailedAndCleanup({ entry, cfg, log, stateDir, attemptId });
@@ -599,16 +599,12 @@ async function recordRecoveredFailure(
}).fail(record, error);
}
async function markDurableDeliveryFailedBestEffort(
entry: QueuedDelivery,
log: RecoveryLogger,
stateDir?: string,
): Promise<void> {
function markDurableDeliveryFailedBestEffort(entry: QueuedDelivery, log: RecoveryLogger): void {
if (!entry.deliveryCompletion) {
return;
}
try {
await failDurableDelivery(entry.deliveryCompletion, stateDir);
failDurableDelivery(entry.deliveryCompletion);
} catch (error) {
// Queue ownership is authoritative for replay safety. Missing owner state
// must not leave a dead-lettered delivery permanently replayable.
@@ -630,9 +626,9 @@ async function resolveCompletedOwnerBeforeRecovery(opts: {
if (!completion) {
return "continue";
}
let operation: Awaited<ReturnType<typeof markDurableDeliveryQueued>>;
let operation: ReturnType<typeof markDurableDeliveryQueued>;
try {
operation = await markDurableDeliveryQueued(completion, opts.entry.id);
operation = markDurableDeliveryQueued(completion, opts.entry.id);
} catch (error) {
const errMsg = `delivery owner state unavailable: ${formatErrorMessage(error)}`;
await recordRecoveredFailure(failDelivery, opts.entry, errMsg, opts.stateDir).catch(
@@ -642,7 +638,7 @@ async function resolveCompletedOwnerBeforeRecovery(opts: {
opts.log.warn(`Delivery entry ${opts.entry.id} ${errMsg}`);
return "failed";
}
if (operation.state === "delivered") {
if (operation.status === "sent" || operation.status === "replied") {
try {
await ackRecoveredDelivery(opts.entry, opts.stateDir);
} catch (error) {
@@ -651,7 +647,7 @@ async function resolveCompletedOwnerBeforeRecovery(opts: {
opts.log.warn(`Delivery entry ${opts.entry.id} ${errMsg}`);
return "failed";
}
const messageId = operation.platformMessageId;
const messageId = operation.platformMessageId ?? operation.preparedMessageId;
if (messageId) {
const result: OutboundDeliveryResult = { channel: opts.entry.channel, messageId };
emitRecoveredTerminalSuccess(opts.entry, result);
@@ -667,7 +663,7 @@ async function resolveCompletedOwnerBeforeRecovery(opts: {
opts.onRecovered?.(opts.entry);
return "recovered";
}
if (operation.state === "suppressed" || operation.state === "stale") {
if (operation.status === "suppressed") {
try {
await (typeof opts.entry.completionRetention === "object"
? ackRecoveredDelivery(opts.entry, opts.stateDir, { suppressCompletionReceipt: true })
@@ -681,7 +677,7 @@ async function resolveCompletedOwnerBeforeRecovery(opts: {
opts.onRecovered?.(opts.entry);
return "recovered";
}
if (operation.state === "rejected") {
if (operation.status === "rejected") {
try {
await (typeof opts.entry.completionRetention === "object"
? ackRecoveredDelivery(opts.entry, opts.stateDir, { suppressCompletionReceipt: true })
@@ -700,13 +696,17 @@ async function resolveCompletedOwnerBeforeRecovery(opts: {
failureStage: "platform_send",
}),
);
const error =
operation.rejectionError ?? "delivery permanently rejected before platform dispatch";
emitRecoveredTerminalFailure(opts.entry, error);
opts.onFailed?.(opts.entry, error);
emitRecoveredTerminalFailure(
opts.entry,
operation.rejectionError ?? "delivery permanently rejected before platform dispatch",
);
opts.onFailed?.(
opts.entry,
operation.rejectionError ?? "delivery permanently rejected before platform dispatch",
);
return "failed";
}
if (operation.state === "unknown") {
if (operation.status === "unknown") {
const moved = await moveEntryToFailedWithLogging(opts.entry, opts.cfg, opts.log, opts.stateDir);
return moved ? "moved-to-failed" : "failed";
}
@@ -773,7 +773,7 @@ async function drainQueuedEntry(opts: {
try {
const result = buildReconciledSentResult(entry, reconciliation);
if (entry.deliveryCompletion) {
await completeDurableDelivery(entry.deliveryCompletion, result, opts.stateDir);
completeDurableDelivery(entry.deliveryCompletion, result);
}
await ackRecoveredDelivery(entry, opts.stateDir, undefined, entry.platformSendAttemptId);
emitRecoveredTerminalSuccess(entry, result);
@@ -850,7 +850,7 @@ async function drainQueuedEntry(opts: {
return "failed";
}
try {
await markDurableDeliveryFailedBestEffort(entry, opts.log, opts.stateDir);
markDurableDeliveryFailedBestEffort(entry, opts.log);
const attemptId = recoveryPlatformAttemptId(entry);
await moveEntryToFailedAndCleanup({
entry,
@@ -927,7 +927,7 @@ async function drainQueuedEntry(opts: {
: await reserveDeliveryAttempt(entry.id, maxRetries, opts.stateDir);
if (reservation.status === "exhausted") {
const errMsg = `delivery retry budget exhausted (${reservation.attemptCount}/${maxRetries})`;
await markDurableDeliveryFailedBestEffort(entry, opts.log, opts.stateDir);
markDurableDeliveryFailedBestEffort(entry, opts.log);
try {
await moveEntryToFailedAndCleanup({
entry,
@@ -993,6 +993,11 @@ async function drainQueuedEntry(opts: {
}
if (results.length > 0) {
deliveredResults = [...results];
if (entry.deliveryCompletion) {
completeDurableDelivery(entry.deliveryCompletion, results.at(-1)!);
}
} else if (entry.deliveryCompletion) {
suppressDurableDelivery(entry.deliveryCompletion);
}
const failedOutcomes = payloadOutcomes.filter((outcome) => outcome.status === "failed");
const failedOutcome = failedOutcomes[0];
@@ -1030,13 +1035,6 @@ async function drainQueuedEntry(opts: {
}
return "failed";
}
if (entry.deliveryCompletion) {
if (results.length > 0) {
await completeDurableDelivery(entry.deliveryCompletion, results.at(-1)!, opts.stateDir);
} else {
await suppressDurableDelivery(entry.deliveryCompletion, opts.stateDir);
}
}
postSendState ??=
results.length > 0
? await persistRecoveredPostSendState({
@@ -1157,13 +1155,9 @@ async function drainQueuedEntry(opts: {
if (permanentPlatformRejection || isPermanentDeliveryError(errMsg)) {
try {
if (permanentPlatformRejection && entry.deliveryCompletion) {
await rejectDurableDelivery(
entry.deliveryCompletion,
permanentPlatformRejection.message,
opts.stateDir,
);
rejectDurableDelivery(entry.deliveryCompletion, permanentPlatformRejection.message);
} else {
await markDurableDeliveryFailedBestEffort(entry, opts.log, opts.stateDir);
markDurableDeliveryFailedBestEffort(entry, opts.log);
}
await moveEntryToFailedAndCleanup({
entry,
@@ -1262,7 +1256,7 @@ export async function drainPendingDeliveries(opts: {
!needsUnknownSendReconciliation(currentEntry)
) {
try {
await markDurableDeliveryFailedBestEffort(currentEntry, opts.log, opts.stateDir);
markDurableDeliveryFailedBestEffort(currentEntry, opts.log);
const attemptId = recoveryPlatformAttemptId(currentEntry);
await moveEntryToFailedAndCleanup({
entry: currentEntry,
@@ -459,13 +459,6 @@ suite.define(() => {
await expect.poll(() => popover.textContent()).toContain("$0.018");
await expect.poll(() => popover.textContent()).toContain("$0.0015");
await expect.poll(() => popover.textContent()).toContain("$0.0005");
await expect
.poll(async () =>
(await popover.locator(".context-usage__provenance").allTextContents()).map((text) =>
text.replace(/\s+/g, " ").trim(),
),
)
.toEqual(["Provider: openai", "Model: gpt-5.5"]);
await page.keyboard.press("Escape");
await expect.poll(() => popover.isHidden()).toBe(true);
@@ -205,7 +205,6 @@ describe("renderChatComposer context usage", () => {
"Usage credits $157.85 of $400.00",
]);
expect(container.querySelector(".context-usage__stats")).not.toBeNull();
expect(container.querySelector(".context-usage__stats--cost")).toBeNull();
expect(container.textContent).not.toContain("Est. cost");
});
@@ -269,13 +268,12 @@ describe("renderChatComposer context usage", () => {
row.textContent?.replace(/\s+/g, " ").trim(),
),
).toEqual(["Provider: OpenAI", "Provider: Claude"]);
expect(container.querySelector(".context-usage__stats--cost")).toBeNull();
expect(container.textContent).not.toContain("Est. cost");
expect(container.textContent).not.toContain("Cost by Type");
expect(container.textContent).not.toContain("Model:");
});
it("keeps genuine zero-cost model provenance ahead of transcript bookkeeping", () => {
it("omits the cost-by-type section when every recorded cost is zero", () => {
const container = renderComposer({
messages: [
{ role: "user", content: "hi" },
@@ -310,17 +308,7 @@ describe("renderChatComposer context usage", () => {
} as never,
});
expect(
[...container.querySelectorAll(".context-usage__stats--cost dd")].map((value) =>
value.textContent?.trim(),
),
).toEqual(["$0.00", "$0.00", "$0.00", "$0.00"]);
expect(
[...container.querySelectorAll(".context-usage__provenance")].map((row) =>
row.textContent?.replace(/\s+/g, " ").trim(),
),
).toEqual(["Provider: openai", "Model: gpt-zero"]);
expect(container.textContent).not.toContain("gateway-injected");
expect(container.textContent).not.toContain("Cost by Type");
});
it("prioritizes a matching session provider over historical response provenance", () => {
@@ -387,7 +375,6 @@ describe("renderChatComposer context usage", () => {
row.textContent?.replace(/\s+/g, " ").trim(),
),
).toEqual(["Provider: Claude", "Provider: OpenAI"]);
expect(container.querySelector(".context-usage__stats--cost")).toBeNull();
expect(container.textContent).not.toContain("Model:");
});
-1
View File
@@ -2658,7 +2658,6 @@ describe("chat loading skeleton", () => {
expect(context?.closest(".agent-chat__composer-footer")).not.toBeNull();
// The session provider matches a plan-usage group, so dollar estimates
// yield to the subscription windows.
expect(container.querySelector(".context-usage__stats--cost")).toBeNull();
expect(container.querySelector("[data-chat-usage-provider='true']")?.textContent).toContain(
"OpenAI",
);
@@ -33,7 +33,6 @@ type ProviderCostStats = {
cacheRead?: number;
cacheWrite?: number;
provider: string | null;
model: string | null;
};
function readCostValue(
@@ -60,10 +59,6 @@ function latestProviderCostStats(messages: unknown[] | undefined): ProviderCostS
const usageCost = readCostRecord(readCostRecord(message.usage)?.cost);
const stats: ProviderCostStats = {
provider: typeof message.provider === "string" ? message.provider.trim() || null : null,
model:
(typeof message.responseModel === "string" ? message.responseModel.trim() : "") ||
(typeof message.model === "string" ? message.model.trim() : "") ||
null,
};
for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
const cost = readCostValue(directCost, key) ?? readCostValue(usageCost, key);
@@ -370,7 +365,7 @@ export function renderContextNotice(
const formatStat = (value: number | null) =>
value === null ? t("usage.common.emptyValue") : formatCompactTokenCount(value);
const renderCostStat = (label: string, value: number | undefined) =>
value === undefined
value === undefined || value <= 0
? nothing
: html`
<div>
@@ -378,6 +373,14 @@ export function renderContextNotice(
<dd>${formatCost(value)}</dd>
</div>
`;
const hasProviderCosts = providerCosts
? [
providerCosts.input,
providerCosts.output,
providerCosts.cacheRead,
providerCosts.cacheWrite,
].some((value) => value !== undefined && value > 0)
: false;
return html`
<div
class="context-usage"
@@ -456,31 +459,15 @@ export function renderContextNotice(
</dl>
`
: nothing}
${showCosts && providerCosts
${showCosts && providerCosts && hasProviderCosts
? html`
<div class="context-usage__section-label">${t("usage.breakdown.costByType")}</div>
<dl class="context-usage__stats context-usage__stats--cost">
<dl class="context-usage__stats">
${renderCostStat(t("usage.breakdown.input"), providerCosts.input)}
${renderCostStat(t("usage.breakdown.output"), providerCosts.output)}
${renderCostStat(t("usage.breakdown.cacheRead"), providerCosts.cacheRead)}
${renderCostStat(t("usage.breakdown.cacheWrite"), providerCosts.cacheWrite)}
</dl>
${providerCosts.provider
? html`
<div class="context-usage__provenance">
<span>${t("sessionsView.provider")}:</span>
<strong>${providerCosts.provider}</strong>
</div>
`
: nothing}
${providerCosts.model
? html`
<div class="context-usage__provenance">
<span>${t("sessionsView.model")}:</span>
<strong>${providerCosts.model}</strong>
</div>
`
: nothing}
`
: nothing}
${planGroups.map((group) => renderQuotaGroup(group, usageHref))}
+22 -24
View File
@@ -525,8 +525,8 @@ openclaw-chat-page {
right: 0;
bottom: calc(100% + 10px);
z-index: 70;
width: min(340px, calc(100vw - 42px));
padding: 16px;
width: min(300px, calc(100vw - 42px));
padding: 12px;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--popover) 96%, var(--card));
@@ -559,7 +559,7 @@ openclaw-chat-page {
.context-usage__bar {
height: 5px;
margin: 12px 0 15px;
margin: 8px 0 10px;
overflow: hidden;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--muted) 22%, transparent);
@@ -684,40 +684,38 @@ openclaw-chat-page {
}
.context-usage__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 8px;
margin: 10px 0 0;
}
.context-usage__stats--cost {
grid-template-columns: repeat(2, minmax(0, 1fr));
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
margin: 8px 0 0;
font-size: 12px;
}
.context-usage__stats div {
display: inline-flex;
align-items: baseline;
gap: 4px;
min-width: 0;
padding: 9px 10px;
border: 1px solid color-mix(in srgb, var(--border) 68%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--card) 72%, transparent);
}
/* Trailing separator: on flex-wrap the dot stays at the end of the previous
line instead of orphaned at the start of the next one. */
.context-usage__stats div:not(:last-child)::after {
margin-left: 2px;
color: var(--muted);
content: "·";
}
.context-usage__stats dt {
color: var(--muted);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.context-usage__stats dd {
margin: 4px 0 0;
overflow: hidden;
margin: 0;
color: var(--text);
font-size: 14px;
font-weight: 700;
font-weight: 650;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
}
.context-usage__provenance {