From 5235269b3c3807f4be3acfc5755b169744a0ec7e Mon Sep 17 00:00:00 2001 From: James Tatum Date: Wed, 12 Aug 2026 03:55:38 -0700 Subject: [PATCH] =?UTF-8?q?fix(agents):=20heartbeats=20lose=20all=20conver?= =?UTF-8?q?sation=20context=20on=20claude-cli=20chat=E2=86=94heartbeat=20t?= =?UTF-8?q?ransitions=20(#121509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agents): resolve session-stable reply mode for direct resolver turns Heartbeat wakes and other direct getReplyFromConfig callers bypass dispatch, so no session-stable delivery mode reached their CLI binding facts and prepare computed no messageToolPolicyHash, while dispatched chat turns hashed the stable mode. resolveCliSessionReuse treats that one-sided hash as a policy change, hard-invalidating the binding on every chat<->heartbeat transition and leaving heartbeats to run in fresh CLI sessions with no conversation history (historyPrompt=none). Extract the synthetic-turn stable-mode resolution that prepareAgentCommandExecution already used into resolveSessionStableReplyMode, and apply it in prepareReplyRunContext whenever a synthetic turn arrives without dispatch's injected mode, so every turn kind on a session derives the same binding facts and hash. Fixes #121485 Co-Authored-By: Claude Fable 5 * fix(agents): derive synthetic binding facts from the dispatch policy owner Consolidate the synthetic-turn stable-mode resolution onto dispatch's own policy pieces instead of a simplified parallel resolver: - resolveVisibleRepliesPolicy is extracted from dispatch prepare-context (configured visible-replies guard + harness default chain) and shared, so the fallback and dispatch cannot drift on harness defaults, live-vs-entry chat facts, or ExplicitDeliverRoute. - The stable fact now applies dispatch's messageToolAvailable downgrade via the canonical tool-policy resolvers: tool-only delivery with a policy-denied message tool records automatic, matching dispatch. - Synthetic turns no longer fall back to their effective turn mode: a response-tool heartbeat's message_tool_only is per-turn enforcement, not session policy, and previously bypassed the stable fallback entirely. Regression coverage: response-tool heartbeat facts match dispatched turns, and a message-tool-denied config downgrades the synthetic stable fact. Refs #121485 Co-Authored-By: Claude Fable 5 * fix(agents): keep the session-stable reply mode sender-independent Dispatch's stable-mode downgrade used the turn's sender-aware message-tool availability, so a sender-scoped message denial hashed the stable policy as automatic on chat turns while sender-less synthetic turns hashed tool-only — the same binding reset loop on another axis. resolveStableMessageToolAvailability becomes the one sender-independent owner: dispatch passes it as sessionStableMessageToolAvailable for the stable-mode resolution only (effective per-turn enforcement keeps the sender-aware verdict), and synthetic binding facts already consume it. Computed only when the visible-replies candidate is message_tool. Refs #121485 Co-Authored-By: Claude Fable 5 * fix(agents): derive stable reply facts from session surfaces, not wake plumbing Third-pass review fixes on the #121485 consolidation: - Strip system-event wake providers ("heartbeat", "cron-event") from the stable context and resolve origin-less entries as internal, so synthetic facts take the same internal-channel branch dispatch's live webchat turns do instead of falling through to harness defaults. - Fall back to persisted session facts (delivery channel/origin, groupId, groupChannel/subject, accountId) for the sender-independent availability stack, so bare-ctx callers like command prepare resolve the same group/account-scoped policies as dispatched turns; dispatch now passes its session entry too. - Collapse the redundant second mode resolution into the availability downgrade, surface dispatch's injected stable mode separately from resolvePromptSourceReplyMode so the synthetic slot has one owner, and reuse resolveTurnModelOverride instead of an inline copy. - Refresh docs/.generated/plugin-sdk-api-baseline.jsonl: closure hashes only (17 exports, zero declaration changes, verified) — plugin-sdk reply/meeting entrypoints transitively bundle the reply pipeline this branch refactors. Regression: origin-less entry heartbeat resolves internal-automatic facts. Refs #121485 Co-Authored-By: Claude Fable 5 * chore: adapt to main module moves and split API baseline Mechanical rebase refresh onto d3696f8d769: subagent-capabilities moved to src/agents/subagents/spawn/, and the plugin-sdk API baseline is now per-entrypoint content hashes (#122082) — regenerated for the reply-pipeline closure this branch touches. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Ayaan Zaidi --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- src/agents/command/prepare.ts | 48 +---- .../dispatch-from-config.harness-defaults.ts | 45 ++++- .../dispatch-from-config.prepare-context.ts | 44 +++-- src/auto-reply/reply/get-reply-run-context.ts | 37 +++- .../reply/get-reply-run-source-mode.ts | 8 +- .../reply/get-reply-run.media-only.test.ts | 145 ++++++++++++++ .../reply/session-stable-reply-mode.ts | 182 ++++++++++++++++++ .../reply/source-reply-delivery-mode.test.ts | 34 ++++ .../reply/source-reply-delivery-mode.ts | 10 +- 25 files changed, 502 insertions(+), 83 deletions(-) create mode 100644 src/auto-reply/reply/session-stable-reply-mode.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 72ef38a36716..2c86504e3d73 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"656879cbd7d8c60a593ea053ccb8ba0536d4b5eb5e4ae61755a31d3e18a49c42","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"59fb241bbe47669d5d7f99a945a18d86f318b9af8c4c281bb11f01506c84ebbf","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index c8eaa76bb235..d8bd1df6bd82 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"72bf26feecd57463e402f8ed15e6b4edba23fc8276ebc2f6ae55d016a4fef381","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"b7195e12bbe582de01faacc9d3a22f5bc30e0210f7b0c03d3b6242b969f7a0be","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index e6555eb6de7e..c916c343013f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"7c1094e554ba440a2204f18658611682a101e12dc3d79c1005ae8f40fd766d52","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"04848d6e3e592c5edf260afa6ab27452770f99ef09e110809fab0813fd1671b0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index b2f89580b410..02b270894ae1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"6bad9d034375b1641f89f78f224826127f9e8b74f29cd9086d24ac721e8bd714","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"656e29f955e218c9c35843a876eecd769b722e64fb8ea1849f266fa9a430ffd1","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index b77980e91d7b..b63227e93569 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"74ba73d4b1b1e114886afabfb3942d72e6062b07486cd2d6f910c61d7183dabe","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"f57838a5ab613e8e168c74cb309beef813587f5465ff80f18b8171320f579e7a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index cf41cb8284f8..fa5b42274078 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"e71a02577d9098115855236dfb546ca2c2a5d05c855ae0c30d90f1fdcf3fad6a","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"0b81e57bcb56659f98b52d55a1dea6f987bae23500a62d56daf4e86065929ac9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 8b06f34ab80d..5e031f0b5935 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"b902346ba2e50c3ebf787aefa8b3415cd65fb87000d42278a6db9ee4960f9aa1","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"57eaf2d515736c9a6880da9434dd6456e7744e75ac8a946709d9e5f30b166aed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 986d3c27c4cb..872ff135b1d1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"c1deb3dd9ab64a993067f7f736ae31b40dee5b96e03e2196c3c482cd911ac0ff","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"bb239ef24687ac9b89335858dff0008f3b861546c242e3af3545fbcae1878309","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 05cfb1cee958..87ac95ea1cca 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"4f8b3a784b00784d7a250b5489bbc20d099f824b8d9ff914dd8d0c93389b07de","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"34a3ec8d182434d7682a6ae2258a4b1797ffc20f62a7a0449ca3055ccc8c69c5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 727ebf40a932..cf22bb77fc49 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"1a0fef1510e351de8902c038e6cde7915707897dcc999455806e5063cb75af18","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"080a0e85aaff22d1e860781d779254461b05e40e4af84b37197b9ae6281ca566","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index d0bbfd77ae0f..c8116ff80bce 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"3cf394e9cb5f76a7fa44727b7e13adcd8dff1b9d4b5b48e9ac2d0adc06f17a0e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"ecf1ca74a42f003b046e3bf2578bae03c609050c6cb16fa741f763d692937e67","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index a03fc757016b..0cc601906bfe 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"1489259884d6df7354edd810aa984442ac3851451ba5991c85f491c99357281a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"a8e907e41641913ebb36b982bb320090197af1408a0196672c15d038883cf1e0","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index d0f9cde6da22..4c888362e736 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"af318d3b8a4edec2d40ad123b97e076910c09b7d517cb03588a5f3c20b808f31","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"8d4c89bf2968217111f333c149b51bb6798dd961a62fbafeef1c19f2473e7ab6","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 0329732a7a95..27cdf069b69c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"e04b2dea0c6c27657372b826db2870cd399ed9f13d5d76d006dd71ac60024187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"5772bc63ec00a1de6e76827a9a8f79c1ed61493d07f6c2bb21f50c18a806e187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index b6ec2050baf4..5dc9f0ffad81 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"10bdd5bfc10ab30dad145d36d7a78a052f52960c4f8e534a0ba258f5f777dba5","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"993018e34f2e7951c2a0412a4df0cd4be50256da04f3d6a539942c3a8168da24","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 0f7db34d13d5..3d26f79a7fcb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"56937e6aa9f4060b93d632f4271bbaa9ba992a7a2fe4c9a6f4b5a5c90b0a41da","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"079ce3652967694b58caa87ed04cdaba07b3766d82c491faa9a6f974d584e18b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/src/agents/command/prepare.ts b/src/agents/command/prepare.ts index 7924c0700430..f664926a3a49 100644 --- a/src/agents/command/prepare.ts +++ b/src/agents/command/prepare.ts @@ -1,8 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - isSyntheticSourceReplyTurn, - resolveSourceReplyDeliveryMode, -} from "../../auto-reply/reply/source-reply-delivery-mode.js"; +import { resolveSessionStableReplyMode } from "../../auto-reply/reply/session-stable-reply-mode.js"; +import { isSyntheticSourceReplyTurn } from "../../auto-reply/reply/source-reply-delivery-mode.js"; import { formatThinkingLevels, normalizeThinkLevel, @@ -31,10 +29,6 @@ import { resolveAgentHarnessSessionContextError, } from "../../sessions/agent-harness-session-key.js"; import { resolveUserPath } from "../../utils.js"; -import { - sessionDeliveryChannel, - sessionDeliveryOrigin, -} from "../../utils/delivery-context.shared.js"; import { isDeliverableMessageChannel, resolveMessageChannel } from "../../utils/message-channel.js"; import { resolveAgentRuntimeConfig } from "../agent-runtime-config.js"; import { @@ -45,7 +39,6 @@ import { resolveAgentWorkspaceDir, } from "../agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; -import { selectAgentHarness } from "../harness/selection.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import type { ModelManifestNormalizationContext } from "../model-ref-shared.js"; import { buildConfiguredModelCatalog, resolveConfiguredModelRef } from "../model-selection.js"; @@ -345,42 +338,15 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti isHeartbeat: commandOpts.bootstrapContextRunKind === "heartbeat", }) ) { - // Lifecycle turns keep their effective delivery mode, but CLI reuse belongs - // to the existing session's normal source-reply policy. - const stableReplyContext = { - CommandAuthorized: false, - ChatType: sessionEntryRaw.chatType, - Provider: sessionDeliveryOrigin(sessionEntryRaw)?.provider, - Surface: sessionDeliveryChannel(sessionEntryRaw), - InputProvenance: commandOpts.inputProvenance, - }; - const stableProvider = sessionEntryRaw.modelProvider ?? configuredModel.provider; - const stableModel = sessionEntryRaw.model ?? configuredModel.model; - const stableRuntime = resolveEffectiveAgentRuntime({ - cfg, - provider: stableProvider, - modelId: stableModel, - agentId: sessionAgentId, - sessionKey, - sessionEntry: sessionEntryRaw, - }); - const harness = selectAgentHarness({ - provider: stableProvider, - modelId: stableModel, - config: cfg, - agentId: sessionAgentId, - sessionKey, - agentHarnessRuntimeOverride: stableRuntime, - }); - const defaultVisibleReplies = - harness.deliveryDefaults?.visibleReplies ?? harness.deliveryDefaults?.sourceVisibleReplies; commandOpts = { ...commandOpts, cliSessionBindingFacts: { - sourceReplyDeliveryMode: resolveSourceReplyDeliveryMode({ + sourceReplyDeliveryMode: resolveSessionStableReplyMode({ cfg, - ctx: stableReplyContext, - defaultVisibleReplies, + ctx: { CommandAuthorized: false }, + sessionEntry: sessionEntryRaw, + sessionAgentId, + sessionKey, }), }, }; diff --git a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts index 46939198b5ca..c318fa2dba58 100644 --- a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts +++ b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts @@ -24,7 +24,6 @@ import { loadSessionStoreEntry, resolveSessionStorePathCore, } from "./dispatch-from-config.runtime.js"; -import type { DispatchFromConfigParams } from "./dispatch-from-config.types.js"; import { resolveStoredModelOverride } from "./stored-model-override.js"; type HarnessSourceVisibleRepliesDefault = "automatic" | "message_tool"; @@ -98,7 +97,7 @@ function resolveHarnessDefaultParentSessionKey(params: { } export function resolveTurnModelOverride( - replyOptions: DispatchFromConfigParams["replyOptions"], + replyOptions: { isHeartbeat?: boolean; heartbeatModelOverride?: string } | undefined, ): string | undefined { if (replyOptions?.isHeartbeat !== true) { return undefined; @@ -205,7 +204,47 @@ function resolveModelOverrideCandidate(params: { })?.ref; } -export function resolveHarnessSourceVisibleRepliesDefault(params: { +/** + * Resolves the configured visible-replies mode plus the guarded harness + * default. One owner for dispatch and synthetic-turn binding facts: both must + * derive the same session-stable delivery mode or CLI session bindings + * ping-pong across turn kinds (#121485). + */ +export function resolveVisibleRepliesPolicy(params: { + cfg: OpenClawConfig; + chatType?: string; + ctx: FinalizedMsgContext; + entry?: SessionEntry; + sessionAgentId: string; + sessionKey?: string; + sessionStore?: Record; + turnModelOverride?: string; +}): { + configuredVisibleReplies?: "automatic" | "message_tool"; + harnessDefaultVisibleReplies?: "automatic" | "message_tool"; +} { + const configuredVisibleReplies = + params.chatType === "group" || params.chatType === "channel" + ? (params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies) + : params.cfg.messages?.visibleReplies; + const harnessDefaultVisibleReplies = + configuredVisibleReplies === undefined && + params.chatType !== "group" && + params.chatType !== "channel" + ? resolveHarnessSourceVisibleRepliesDefault({ + cfg: params.cfg, + ctx: params.ctx, + entry: params.entry, + sessionAgentId: params.sessionAgentId, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + turnModelOverride: params.turnModelOverride, + }) + : undefined; + return { configuredVisibleReplies, harnessDefaultVisibleReplies }; +} + +function resolveHarnessSourceVisibleRepliesDefault(params: { cfg: OpenClawConfig; ctx: FinalizedMsgContext; entry?: SessionEntry; diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts index 275e0236b185..fc399771a3f3 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts @@ -35,8 +35,8 @@ import { } from "./dispatch-from-config.context.js"; import type { PluginBindingTranscriptOwner } from "./dispatch-from-config.events.js"; import { - resolveHarnessSourceVisibleRepliesDefault, resolveTurnModelOverride, + resolveVisibleRepliesPolicy, } from "./dispatch-from-config.harness-defaults.js"; import { extendPreparedDispatchState } from "./dispatch-from-config.phase-state.js"; import type { PrepareDispatchDeliveryReadyState } from "./dispatch-from-config.prepare-delivery.js"; @@ -46,6 +46,7 @@ import { emitMessageReceivedHooks as emitSharedMessageReceivedHooks } from "./me import { resolveOriginMessageProvider } from "./origin-routing.js"; import { waitForReplyDispatcherIdle } from "./reply-dispatcher.js"; import { isDuplicateRestartRecoverySource } from "./restart-recovery-claim.js"; +import { resolveStableMessageToolAvailability } from "./session-stable-reply-mode.js"; import { isExplicitSourceReplyCommand, isUnauthorizedTextSlashCommand, @@ -222,22 +223,16 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli ? cfg.surfaces?.[silentReplySurface]?.silentReply : undefined, }) === "allow"; - const configuredVisibleReplies = - chatType === "group" || chatType === "channel" - ? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies) - : cfg.messages?.visibleReplies; - const harnessDefaultVisibleReplies = - configuredVisibleReplies === undefined && chatType !== "group" && chatType !== "channel" - ? resolveHarnessSourceVisibleRepliesDefault({ - cfg, - ctx, - entry: sessionStoreEntry.entry, - sessionAgentId, - sessionKey: acpDispatchSessionKey, - sessionStore: sessionStoreEntry.store, - turnModelOverride: resolveTurnModelOverride(params.replyOptions), - }) - : undefined; + const { configuredVisibleReplies, harnessDefaultVisibleReplies } = resolveVisibleRepliesPolicy({ + cfg, + chatType, + ctx, + entry: sessionStoreEntry.entry, + sessionAgentId, + sessionKey: acpDispatchSessionKey, + sessionStore: sessionStoreEntry.store, + turnModelOverride: resolveTurnModelOverride(params.replyOptions), + }); const effectiveVisibleReplies = configuredVisibleReplies ?? harnessDefaultVisibleReplies; const prefersMessageToolDelivery = params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" || @@ -299,6 +294,20 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli subagentPolicy, inheritedToolPolicy, ]); + // The stable mode's tool-only downgrade must be sender-independent, or a + // sender-scoped message denial hashes a different binding policy than the + // sender-less synthetic turns on the same session. Only tool-only candidates + // can downgrade, so skip the second policy pass otherwise. + const sessionStableMessageToolAvailable = + effectiveVisibleReplies === "message_tool" + ? resolveStableMessageToolAvailability({ + cfg, + ctx, + sessionEntry: sessionStoreEntry.entry, + sessionAgentId, + sessionKey: acpDispatchSessionKey, + }) + : undefined; const sourceReplyPolicyParams = { cfg, ctx, @@ -308,6 +317,7 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli explicitSuppressTyping: params.replyOptions?.suppressTyping === true, shouldSuppressTyping: state.shouldSuppressTyping, messageToolAvailable, + sessionStableMessageToolAvailable, isHeartbeat: params.replyOptions?.isHeartbeat, } as const; let sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ diff --git a/src/auto-reply/reply/get-reply-run-context.ts b/src/auto-reply/reply/get-reply-run-context.ts index 36346eda876b..40971b686ff7 100644 --- a/src/auto-reply/reply/get-reply-run-context.ts +++ b/src/auto-reply/reply/get-reply-run-context.ts @@ -24,6 +24,7 @@ import { resolveEnvelopeFormatOptions } from "../envelope.js"; import { normalizeThinkLevel } from "../thinking.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { applySessionHints } from "./body.js"; +import { resolveTurnModelOverride } from "./dispatch-from-config.harness-defaults.js"; import { shouldUseReplyFastTestRuntime } from "./get-reply-fast-path.js"; import { buildExecOverridePromptHint, @@ -49,7 +50,11 @@ import { resolveBareResetBootstrapFileAccess, resolveBareSessionResetPromptState, } from "./session-reset-prompt.js"; -import { isExplicitSourceReplyCommand } from "./source-reply-delivery-mode.js"; +import { resolveSessionStableReplyMode } from "./session-stable-reply-mode.js"; +import { + isExplicitSourceReplyCommand, + isSyntheticSourceReplyTurn, +} from "./source-reply-delivery-mode.js"; import { shouldApplyStartupContext, buildSessionStartupContextPrelude } from "./startup-context.js"; import { resolveTypingMode } from "./typing-mode.js"; import { resolveRunTypingPolicy } from "./typing-policy.js"; @@ -107,8 +112,34 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { isHeartbeat, }); const inboundEventKind = promptSessionCtx.InboundEventKind; - const { sourceReplyDeliveryMode, sessionPromptSourceReplyDeliveryMode } = - resolvePromptSourceReplyMode({ promptSessionCtx, opts }); + const { sourceReplyDeliveryMode, injectedSessionStableMode } = resolvePromptSourceReplyMode({ + promptSessionCtx, + opts, + }); + // Direct resolver callers (heartbeat wakes, system events) skip dispatch's + // stable-mode injection; resolve the same session-stable fact here so their + // binding facts and messageToolPolicyHash match dispatched chat turns — + // otherwise chat<->heartbeat transitions ping-pong the CLI session (#121485). + // Synthetic turns must not fall back to their effective turn mode: a + // response-tool heartbeat's message_tool_only is per-turn enforcement, not + // session policy, and hashing it recreates the ping-pong. + const isSyntheticTurn = isSyntheticSourceReplyTurn({ + inputProvenance: promptSessionCtx.InputProvenance, + isHeartbeat, + }); + const sessionPromptSourceReplyDeliveryMode = + injectedSessionStableMode ?? + (isSyntheticTurn && sessionEntry + ? resolveSessionStableReplyMode({ + cfg, + ctx: { ...promptSessionCtx, CommandAuthorized: false }, + sessionEntry, + sessionAgentId: agentId, + sessionKey, + sessionStore, + turnModelOverride: resolveTurnModelOverride(opts), + }) + : sourceReplyDeliveryMode); const silentReplyConversationType = resolvePromptSilentReplyConversationType({ ctx: promptSessionCtx, inboundSessionKey: ctx.SessionKey, diff --git a/src/auto-reply/reply/get-reply-run-source-mode.ts b/src/auto-reply/reply/get-reply-run-source-mode.ts index a2c80a39dd41..d99736ca5e50 100644 --- a/src/auto-reply/reply/get-reply-run-source-mode.ts +++ b/src/auto-reply/reply/get-reply-run-source-mode.ts @@ -2,6 +2,11 @@ import type { TemplateContext } from "../templating.js"; import type { InternalGetReplyOptions } from "./get-reply-run.types.js"; import { isInternalSourceReplyChannel } from "./source-reply-delivery-mode.js"; +/** + * Resolves the turn's effective source-reply mode and surfaces dispatch's + * injected session-stable mode separately, so the caller owns the synthetic + * fallback in one place instead of un-mixing the two afterwards. + */ export function resolvePromptSourceReplyMode(params: { promptSessionCtx: TemplateContext; opts?: InternalGetReplyOptions; @@ -15,7 +20,6 @@ export function resolvePromptSourceReplyMode(params: { : params.opts?.sourceReplyDeliveryMode; return { sourceReplyDeliveryMode, - sessionPromptSourceReplyDeliveryMode: - params.opts?.sessionPromptSourceReplyDeliveryMode ?? sourceReplyDeliveryMode, + injectedSessionStableMode: params.opts?.sessionPromptSourceReplyDeliveryMode, }; } diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 96c19fcb83c1..148f8a38cbbd 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -3090,6 +3090,16 @@ describe("runPreparedReply media-only handling", () => { sourceReplyDeliveryMode ?? "automatic", ].join(":"), ); + // The direct-caller heartbeat run below resolves the stable mode from + // config instead of injected opts; keep both sources agreeing per case. + const caseCfg = { + session: {}, + channels: {}, + agents: { defaults: {} }, + ...(stableMode === "message_tool_only" + ? { messages: { visibleReplies: "message_tool" as const } } + : {}), + }; const sessionEntry: SessionEntry = { sessionId: "session-telegram-group", updatedAt: 1, @@ -3107,6 +3117,7 @@ describe("runPreparedReply media-only handling", () => { }; await runPrepared({ + cfg: caseCfg, opts: { sourceReplyDeliveryMode: "message_tool_only", sessionPromptSourceReplyDeliveryMode: stableMode, @@ -3125,6 +3136,7 @@ describe("runPreparedReply media-only handling", () => { }, }); await runPrepared({ + cfg: caseCfg, opts: { sourceReplyDeliveryMode: stableMode, sessionPromptSourceReplyDeliveryMode: stableMode, @@ -3142,6 +3154,7 @@ describe("runPreparedReply media-only handling", () => { }, }); await runPrepared({ + cfg: caseCfg, opts: { isHeartbeat: true, sourceReplyDeliveryMode: stableMode, @@ -3160,10 +3173,50 @@ describe("runPreparedReply media-only handling", () => { Provider: "cron-event", }, }); + // Production heartbeat wakes call the reply resolver directly, without + // dispatch's injected delivery modes; their binding facts must still + // match dispatched turns or the CLI session ping-pongs (#121485). + await runPrepared({ + cfg: caseCfg, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); + // Response-tool heartbeats carry an effective message_tool_only turn + // mode; that is per-turn enforcement and must not become the session + // policy fact, or these heartbeats keep ping-ponging the binding. + await runPrepared({ + cfg: caseCfg, + opts: { isHeartbeat: true, sourceReplyDeliveryMode: "message_tool_only" }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); const roomEventRun = requireRunReplyAgentCall(0).followupRun.run; const primaryRun = requireRunReplyAgentCall(1).followupRun.run; const heartbeatRun = requireRunReplyAgentCall(2).followupRun.run; + const directHeartbeatRun = requireRunReplyAgentCall(3).followupRun.run; + const responseToolHeartbeatRun = requireRunReplyAgentCall(4).followupRun.run; expect(roomEventRun.sourceReplyDeliveryMode).toBe("message_tool_only"); expect(primaryRun.sourceReplyDeliveryMode).toBe(stableMode); expect(heartbeatRun.sourceReplyDeliveryMode).toBe(stableMode); @@ -3180,9 +3233,101 @@ describe("runPreparedReply media-only handling", () => { }); expect(primaryRun.cliSessionBindingFacts).toEqual(roomEventRun.cliSessionBindingFacts); expect(heartbeatRun.cliSessionBindingFacts).toEqual(roomEventRun.cliSessionBindingFacts); + expect(directHeartbeatRun.cliSessionBindingFacts).toEqual( + roomEventRun.cliSessionBindingFacts, + ); + expect(responseToolHeartbeatRun.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(responseToolHeartbeatRun.cliSessionBindingFacts).toEqual( + roomEventRun.cliSessionBindingFacts, + ); }, ); + it("resolves origin-less sessions as internal for synthetic stable facts", async () => { + vi.mocked(buildDirectChatContext).mockReturnValue("direct-context"); + // An entry with no persisted delivery origin has only ever been driven + // internally; the wake provider ("heartbeat") must not leak into the + // stable context as a non-internal surface or the fact diverges from + // dispatch's live webchat turns. + const sessionEntry: SessionEntry = { + sessionId: "session-internal", + updatedAt: 1, + systemSent: true, + chatType: "direct", + }; + + await runPrepared({ + cfg: { session: {}, channels: {}, agents: { defaults: {} } }, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:main", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + ChatType: "direct", + }, + }); + + const run = requireRunReplyAgentCall(0).followupRun.run; + expect(run.cliSessionBindingFacts?.sourceReplyDeliveryMode).toBe("automatic"); + }); + + it("downgrades the synthetic stable mode when the message tool is policy-denied", async () => { + vi.mocked(buildGroupChatContext).mockImplementation(({ sourceReplyDeliveryMode }) => + ["group", sourceReplyDeliveryMode ?? "automatic"].join(":"), + ); + const sessionEntry: SessionEntry = { + sessionId: "session-telegram-group", + updatedAt: 1, + systemSent: true, + chatType: "group", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "-100123" }, + origin: { + provider: "telegram", + surface: "telegram", + chatType: "group", + to: "-100123", + }, + }), + }; + + // Tool-only delivery configured, but the message tool is denied: dispatch + // downgrades its stable mode to automatic, so the synthetic fallback must + // record automatic too or the binding hashes diverge again. + await runPrepared({ + cfg: { + session: {}, + channels: {}, + agents: { defaults: {} }, + messages: { visibleReplies: "message_tool" as const }, + tools: { deny: ["message"] }, + }, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); + + const run = requireRunReplyAgentCall(0).followupRun.run; + expect(run.cliSessionBindingFacts?.sourceReplyDeliveryMode).toBe("automatic"); + }); + it("keeps per-message room-event metadata out of CLI binding facts", async () => { vi.mocked(buildGroupChatContext).mockImplementation(({ sessionCtx, sourceReplyDeliveryMode }) => [ diff --git a/src/auto-reply/reply/session-stable-reply-mode.ts b/src/auto-reply/reply/session-stable-reply-mode.ts new file mode 100644 index 000000000000..a7ed993dfef8 --- /dev/null +++ b/src/auto-reply/reply/session-stable-reply-mode.ts @@ -0,0 +1,182 @@ +// Session-stable source-reply mode for synthetic turns (heartbeat wakes, +// system events, inter-session announcements) that reach the reply resolver +// without dispatch's injected delivery-mode facts. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + resolveEffectiveToolPolicy, + resolveGroupToolPolicy, + resolveInheritedToolPolicyForSession, + resolveSubagentToolPolicyForSession, +} from "../../agents/agent-tools.policy.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilityStore, +} from "../../agents/subagents/spawn/subagent-capabilities.js"; +import { isToolAllowedByPolicies } from "../../agents/tool-policy-match.js"; +import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { resolveGroupSessionKey } from "../../config/sessions/group.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + deliveryContextFromSession, + sessionDeliveryChannel, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; +import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; +import type { FinalizedMsgContext } from "../templating.js"; +import { resolveVisibleRepliesPolicy } from "./dispatch-from-config.harness-defaults.js"; +import { isSystemEventProvider } from "./effective-reply-route.js"; +import { resolveOriginMessageProvider } from "./origin-routing.js"; +import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js"; + +/** + * Resolves the session's stable source-reply mode the way dispatch does, from + * a synthetic turn's restored context plus persisted session facts. Synthetic + * turns keep their effective delivery mode, but CLI session reuse belongs to + * the session's normal source-reply policy — every turn kind must derive the + * same messageToolPolicyHash, or chat and heartbeat turns ping-pong the CLI + * binding on each transition (#121485). + */ +export function resolveSessionStableReplyMode(params: { + cfg: OpenClawConfig; + ctx: FinalizedMsgContext; + sessionEntry: SessionEntry; + sessionAgentId: string; + sessionKey?: string; + sessionStore?: Record; + turnModelOverride?: string; +}): SourceReplyDeliveryMode { + const { cfg, ctx, sessionEntry } = params; + const chatType = + normalizeChatType(ctx.ChatType) ?? normalizeChatType(sessionEntry.chatType) ?? undefined; + // System-event provider strings ("heartbeat", "cron-event") are wake + // plumbing, not the session's surface; an entry with no persisted delivery + // origin has only ever been driven internally, so it must resolve the same + // internal-channel branch dispatch's live webchat turns do. + const stableReplyContext = { + CommandAuthorized: false, + ChatType: chatType, + Provider: + resolveStableChannelFact(ctx.Provider) ?? + sessionDeliveryOrigin(sessionEntry)?.provider ?? + INTERNAL_MESSAGE_CHANNEL, + Surface: resolveStableChannelFact(ctx.Surface) ?? sessionDeliveryChannel(sessionEntry), + ExplicitDeliverRoute: ctx.ExplicitDeliverRoute, + }; + const { harnessDefaultVisibleReplies } = resolveVisibleRepliesPolicy({ + cfg, + chatType, + ctx, + entry: sessionEntry, + sessionAgentId: params.sessionAgentId, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + turnModelOverride: params.turnModelOverride, + }); + const candidateMode = resolveSourceReplyDeliveryMode({ + cfg, + ctx: stableReplyContext, + defaultVisibleReplies: harnessDefaultVisibleReplies, + }); + if (candidateMode !== "message_tool_only") { + return candidateMode; + } + // Dispatch downgrades tool-only delivery to automatic when the message tool + // is policy-denied (source-reply-delivery-mode.ts availability gate); with a + // stable ctx that is the boolean's only effect, so apply it directly rather + // than re-deriving the whole mode. Sender fields are deliberately absent: + // session-stable policy cannot vary by sender. + return resolveStableMessageToolAvailability(params) ? candidateMode : "automatic"; +} + +/** Strips system-event wake providers so only real channel surfaces remain. */ +function resolveStableChannelFact(value: string | undefined): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized && !isSystemEventProvider(normalized) ? normalized : undefined; +} + +/** + * Sender-independent message-tool availability for the session-stable mode. + * One owner for dispatch's stable-mode downgrade and synthetic-turn binding + * facts: sender-scoped denials apply to the sender's turn, never to the + * session policy every turn kind must hash identically (#121485). + */ +export function resolveStableMessageToolAvailability(params: { + cfg: OpenClawConfig; + ctx: FinalizedMsgContext; + sessionEntry?: SessionEntry; + sessionAgentId: string; + sessionKey?: string; +}): boolean { + const { cfg, ctx, sessionEntry } = params; + const { + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + profile, + providerProfile, + profileAlsoAllow, + providerProfileAlsoAllow, + } = resolveEffectiveToolPolicy({ + config: cfg, + sessionKey: params.sessionKey, + agentId: params.sessionAgentId, + }); + // Tool-only delivery force-allows the message tool at the profile layer + // (dispatch's runtimeProfileAlsoAllow); only outer deny layers can make it + // unavailable. + const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [ + ...(profileAlsoAllow ?? []), + "message", + ]); + const providerProfilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(providerProfile), [ + ...(providerProfileAlsoAllow ?? []), + "message", + ]); + // Direct callers (command prepare, synthetic wakes) may carry a bare ctx; + // fall back to the persisted session facts dispatch sees on live turns, or + // group/account-scoped policies resolve differently per producer. + const groupPolicy = resolveGroupToolPolicy({ + config: cfg, + sessionKey: params.sessionKey, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: + ctx.OriginatingChannel ?? (sessionEntry ? sessionDeliveryChannel(sessionEntry) : undefined), + provider: + resolveStableChannelFact(ctx.Provider ?? ctx.Surface) ?? + (sessionEntry ? sessionDeliveryOrigin(sessionEntry)?.provider : undefined), + }), + groupId: resolveGroupSessionKey(ctx)?.id ?? sessionEntry?.groupId, + groupChannel: + normalizeOptionalString(ctx.GroupChannel) ?? + normalizeOptionalString(ctx.GroupSubject) ?? + normalizeOptionalString(sessionEntry?.groupChannel) ?? + normalizeOptionalString(sessionEntry?.subject), + groupSpace: normalizeOptionalString(ctx.GroupSpace), + accountId: + ctx.AccountId ?? + (sessionEntry ? deliveryContextFromSession(sessionEntry)?.accountId : undefined), + }); + const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, { cfg }); + const subagentPolicy = + params.sessionKey && isSubagentEnvelopeSession(params.sessionKey, { cfg, store: subagentStore }) + ? resolveSubagentToolPolicyForSession(cfg, params.sessionKey, { store: subagentStore }) + : undefined; + const inheritedToolPolicy = resolveInheritedToolPolicyForSession(cfg, params.sessionKey, { + store: subagentStore, + }); + return isToolAllowedByPolicies("message", [ + profilePolicy, + providerProfilePolicy, + globalProviderPolicy, + agentProviderPolicy, + globalPolicy, + agentPolicy, + groupPolicy, + subagentPolicy, + inheritedToolPolicy, + ]); +} diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index 20feeb74c77e..e9ef1dfc980e 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -436,6 +436,40 @@ describe("resolveSourceReplyVisibilityPolicy", () => { }, ); + it("keeps the stable mode tool-only under a sender-scoped message denial", () => { + // A sender-scoped denial downgrades the sender's effective delivery, but + // the session-stable mode feeds CLI binding facts shared by sender-less + // synthetic turns; downgrading it too splits the policy hash and resets + // the CLI session on chat<->heartbeat transitions. + expectPolicyFields( + resolveSourceReplyVisibilityPolicy({ + cfg: globalToolOnlyReplyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + messageToolAvailable: false, + sessionStableMessageToolAvailable: true, + }), + { + sourceReplyDeliveryMode: "automatic", + sessionStableSourceReplyDeliveryMode: "message_tool_only", + }, + ); + // Without a sender-independent verdict, the stable mode still follows the + // turn's availability (session-wide denials downgrade both). + expectPolicyFields( + resolveSourceReplyVisibilityPolicy({ + cfg: globalToolOnlyReplyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + messageToolAvailable: false, + }), + { + sourceReplyDeliveryMode: "automatic", + sessionStableSourceReplyDeliveryMode: "automatic", + }, + ); + }); + it("suppresses automatic source delivery for opted-in message-tool group turns without suppressing typing", () => { expectPolicyFields( resolveSourceReplyVisibilityPolicy({ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index 72979c828adc..e476f03b2b88 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -153,6 +153,13 @@ export function resolveSourceReplyVisibilityPolicy(params: { explicitSuppressTyping?: boolean; shouldSuppressTyping?: boolean; messageToolAvailable?: boolean; + /** + * Sender-independent availability for the session-stable mode. The stable + * mode feeds CLI binding facts shared by every turn kind, so a sender-scoped + * message-tool denial must not downgrade it while sender-less synthetic + * turns resolve tool-only — that hash split resets the CLI session (#121485). + */ + sessionStableMessageToolAvailable?: boolean; defaultVisibleReplies?: "automatic" | "message_tool"; isHeartbeat?: boolean; }): SourceReplyVisibilityPolicy { @@ -175,7 +182,8 @@ export function resolveSourceReplyVisibilityPolicy(params: { : resolveSourceReplyDeliveryMode({ cfg: params.cfg, ctx: toSessionStableDeliveryModeContext(params.ctx), - messageToolAvailable: params.messageToolAvailable, + messageToolAvailable: + params.sessionStableMessageToolAvailable ?? params.messageToolAvailable, defaultVisibleReplies: params.defaultVisibleReplies, }); const sendPolicyDenied = params.sendPolicy === "deny";