refactor(automations): retire heartbeat executor and response tool (#180192)

* refactor(automations): retire heartbeat executor and response tool

* test(automations): finish retired heartbeat test cutover

* test(ci): retain native subagent authority owner

* fix(ci): distinguish inherited environment reads from overrides
This commit is contained in:
Peter Steinberger 2026-10-22 03:32:37 -07:00 • committed by GitHub
parent 240a90ef9f
commit 5b4f83ce86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
174 changed files with 633 additions and 17473 deletions

View file

@ -1320,7 +1320,6 @@ src/agents/tools/dashboard-tool.ts 1
src/agents/tools/embedded-gateway-stub.ts 7
src/agents/tools/gateway-tool.ts 2
src/agents/tools/goal-tools.ts 2
src/agents/tools/heartbeat-response-tool.ts 1
src/agents/tools/image-tool.helpers.ts 2
src/agents/tools/in-process-gateway.ts 1
src/agents/tools/message-tool-discovery.ts 2
@ -1360,7 +1359,6 @@ src/auto-reply/chunk.ts 1
src/auto-reply/command-auth.ts 3
src/auto-reply/command-turn-context.ts 1
src/auto-reply/heartbeat-filter.ts 2
src/auto-reply/heartbeat-tool-response.ts 2
src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts 1
src/auto-reply/reply/acp-reset-target.ts 1
src/auto-reply/reply/agent-lifecycle-terminal.ts 2
@ -1927,8 +1925,6 @@ src/infra/fs-safe-remove.ts 4
src/infra/gateway-lock.ts 2
src/infra/gateway-processes.ts 1
src/infra/git-commit.ts 1
src/infra/heartbeat-outcome-store.ts 1
src/infra/heartbeat-runner-config.ts 1
src/infra/heartbeat-visibility.ts 1
src/infra/install-package-dir.ts 2
src/infra/json-utf8-bytes.ts 4

View file

@ -5135,7 +5135,6 @@
["src/gateway/server-control-ui-root.resolve.test.ts","../version.js","3bba2ef2e16906adde3706ff6101e39d48df4dbf6b3f66a6ea314a9acb47aec6",1]
["src/gateway/server-cron-notifications.test.ts","../infra/net/fetch-guard.js","03b77e8d42659fce8a2962da9629bc21eae13872d50f9748c3f956c3e5e0c3ad",1]
["src/gateway/server-cron-primary-webhook.test.ts","../infra/net/fetch-guard.js","03b77e8d42659fce8a2962da9629bc21eae13872d50f9748c3f956c3e5e0c3ad",1]
["src/gateway/server-cron.test.ts","../infra/heartbeat-runner.js","f0280a027ac3ddaa6a9dbdeb4c4955af924118ceabe7674584402268e87b5fda",1]
["src/gateway/server-discovery-runtime.test.ts","../infra/tailnet.js","02ce537edaf11c0cdc6785b9acb34e9f8a3cf26efc60c156709ba2921e9d5545",1]
["src/gateway/server-discovery-runtime.test.ts","../infra/widearea-dns.js","3897274553d45ff20947421581b0d59ddcac57a77f8a19db71f4cb4749dd3182",1]
["src/gateway/server-discovery-runtime.test.ts","./server-discovery.js","ffe20feab486ff14a37c3fd9c02de92571bf750369da13bef496fc1246d55437",1]
@ -5611,9 +5610,6 @@
["src/gateway/server-runtime-services.delivery-recovery.test.ts","../infra/session-delivery-queue-runtime.js","dc938411855d7dff693744f71c1985c86cfa73cbc11da5317c96f7ef7961f28e",1]
["src/gateway/server-runtime-services.delivery-recovery.test.ts","../sessions/session-upstream-monitor.js","acd22e92ba34c90767975613e7502810513b219c89f7729f2d5b9a5c5233429e",1]
["src/gateway/server-runtime-services.delivery-recovery.test.ts","./server-restart-sentinel.js","4081694e5665bc04427c1f186c00e3b594b6d50b10ebe6186b14b274db9eb828",1]
["src/gateway/server-runtime-services.heartbeat-lazy.test.ts","../infra/heartbeat-runner-config.js","6b2bcfc7bddfcd0fba624baf52a4db7b66023a2e3f424e8e0a3cb1307ac08a6e",1]
["src/gateway/server-runtime-services.heartbeat-lazy.test.ts","../infra/heartbeat-runner-run.js","30295a552ad41c7f63150690a526c77619582f72475dc4fe3088bb1a4b817ad2",1]
["src/gateway/server-runtime-services.heartbeat-lazy.test.ts","../infra/heartbeat-runner.js","0965ba28fe740a63f755e5f1f7001b3f7a38bca7da92a78829d83336f79394e4",1]
["src/gateway/server-runtime-services.test-harness.ts","../infra/outbound/deliver.js","f866b13490adac5d8a3020ff0d98434ad56de2ffc6ad5e70c1e708a4528a88f1",1]
["src/gateway/server-runtime-services.test-harness.ts","../infra/outbound/delivery-queue-recovery.js","112cf1843bbffaf141f00a1e154d52e4e4ce43b44ba92a618617123370045fe5",1]
["src/gateway/server-runtime-services.test-harness.ts","../infra/session-delivery-queue-runtime.js","814046df181f057cae6929b2fe4e11c9c8856be1fb0eba0628c3c8e395291661",1]
@ -6007,12 +6003,6 @@
["src/infra/exec-approvals-store.read-worker.test.ts","../logging/subsystem.js","71cc680d067f1feb654e6cd97018aff599e7e56faa5fcf55f5d86f3e55a65a0b",1]
["src/infra/gateway-scheduler.test.ts","../logging/subsystem.js","37e837a28be05ecee3458e3f0eac0f7b55cbad0043b767f45acee35d3ca87044",1]
["src/infra/git-read-cache.test.ts","./git-worker.js","73b1601423791dced3e8926147c1acbbc424d262187b7a663c2d4c6ba6471644",1]
["src/infra/heartbeat-runner-scheduler.lazy.test.ts","./heartbeat-runner-config.js","7e1d469fa37b67581f55ba0a500a18894bb47d8b6d99e64bd1c82a8eecaad2c1",1]
["src/infra/heartbeat-runner-scheduler.lazy.test.ts","./heartbeat-runner-run.js","2a20387cb4c0f88d471f1bec5a193a69b940ba4b4e286ba46fe124bd59d74e2b",1]
["src/infra/heartbeat-runner-scheduler.lazy.test.ts","./heartbeat-runner-run.js","de00c3f7ad00911aff6b0d1529161e90126a8974b99d209bdb7ea272556d3073",1]
["src/infra/heartbeat-runner.isolated-key-stability.test.ts","./outbound/deliver.js","d2b232a773e5ec0bae3a4428105b4cc4fbcb9d0ad459bc24f3282e278883f7a2",1]
["src/infra/heartbeat-runner.isolated-session-mirror.test.ts","./outbound/deliver.js","fcc3ec253e49074cc46aa8ad67cdd4e1297033b94929df015241c8f41ced9817",1]
["src/infra/heartbeat-runner.model-override.test.ts","./outbound/deliver.js","d2b232a773e5ec0bae3a4428105b4cc4fbcb9d0ad459bc24f3282e278883f7a2",1]
["src/infra/host-account-avatar.test.ts","../process/exec.js","425c798f7af41aecb4ee59662f9ec9241e392730bf049e612d897d60af393696",1]
["src/infra/host-account-name.test.ts","../process/exec.js","425c798f7af41aecb4ee59662f9ec9241e392730bf049e612d897d60af393696",1]
["src/infra/http-error-body.test.ts","../logging/subsystem.js","a2c0bef014098b443685cf5fde77b9def7a16c91566f92e096ada9dca90578c9",1]

View file

@ -8,7 +8,7 @@ title: "Database worker migration inventory"
<!-- Generated by scripts/database-worker-inventory.mjs. Edit its classification evidence, then regenerate. -->
This snapshot contains **632 non-test files and 2325 call expressions** for the five primitives below. The campaign previously reported 404 files; that is a historical estimate, not a fixed target or a count of call expressions. This inventory follows current source and excludes import-only matches, comments, tests, fixtures, and test support. Its scan scope and exclusions are explicit below.
This snapshot contains **630 non-test files and 2323 call expressions** for the five primitives below. The campaign previously reported 404 files; that is a historical estimate, not a fixed target or a count of call expressions. This inventory follows current source and excludes import-only matches, comments, tests, fixtures, and test support. Its scan scope and exclusions are explicit below.
Regenerate with `pnpm db:worker-inventory:gen`; verify with `pnpm db:worker-inventory:check`. `node scripts/database-worker-inventory.mjs --json` emits every call's primitive, line, column, lexical operation path, optional variable-initializer binding, enclosing synchronous guards, file owner, tier, and classification evidence. The script uses the repository's TypeScript parser, entry-point reference graph, and `rg`; it does not load application code or open a database.
@ -36,7 +36,7 @@ The scan covers JavaScript/TypeScript files under `src/`, `extensions/`, `packag
| T1-compat | 10 | 15 |
| T2 | 111 | 391 |
| T3 | 137 | 407 |
| W | 362 | 1342 |
| W | 360 | 1340 |
## Profile priority and current cutover status
@ -429,7 +429,7 @@ These are live or unproven call expressions, not measured statements per turn. T
| **src/infra** · `src/infra/package-update-activation-status.ts` | 2/0/0/0/0 | 50 | CLI status src/cli/update-cli/status.ts:240 → src/infra/package-update-activation.ts:144; CLI preflight update-command-run.ts:208 → activation.ts:79,40. |
| **src/infra** · `src/infra/restart-handoff.ts` | 1/0/1/0/0 | 397 | Only CLI gateway-cli/register-restart-handoff.ts:61 calls consumeGatewayRestartHandoffSync and its private removeCurrent helper. |
| **src/infra** · `src/infra/restart-intent.ts` | 2/1/0/0/0 | 382 | Native service CLI intent recording and exact-owned cleanup: daemon-cli/lifecycle-restart-intent.ts, lifecycle-unmanaged.ts, daemon/launchd-stop.ts; runtime signal consumption uses restart-lifecycle.worker.ts. Gateway system-agent uses host.request, not daemon lifecycle. |
| **src/infra** · `src/infra/restart-sentinel-store.ts` | 2/2/0/0/0 | 248 | src/infra/restart-sentinel.worker.ts:74,150 plus generated one-shot child in src/infra/update-managed-service-handoff.ts:101,439,1639,1649. |
| **src/infra** · `src/infra/restart-sentinel-store.ts` | 2/2/0/0/0 | 242 | src/infra/restart-sentinel.worker.ts:74,150 plus generated one-shot child in src/infra/update-managed-service-handoff.ts:101,439,1639,1649. |
| **src/infra** · `src/infra/state-migrations.daemon-runtime-pins.ts` | 3/1/1/0/0 | 61 | Doctor config-machine-state / pin fence; exclusive migration lock. |
| **src/infra** · `src/infra/update-candidate-state.shared.ts` | 1/0/0/0/0 | 42 | CLI backup src/cli/update-cli/update-command-database-backup.ts:104 → src/infra/update-database-backup.ts:387; baseline update-command-initialization-run.ts:224 → update-recovery-baseline-capture.ts:343; worker inventory modes update-candidate-state.worker.ts:49,68,73. |
| **src/infra** · `src/infra/update-managed-service-handoff-cancellation.ts` | 1/0/0/0/0 | 265 | readCancelledUpdateFamily is called only by reclamation.ts prepareCancelledCustodyRepair, reached through the lease store's prepareRepair from the `openclaw update repair` CLI (update-repair-command.ts). |
@ -698,14 +698,12 @@ These are live or unproven call expressions, not measured statements per turn. T
| **src/infra** · `src/infra/gateway-boot-lifecycle.kernel.ts` | 0/2/0/0/0 | 54 | SQL readers execute only through gatewayBootReadOperations in the shared-state read worker or gatewayBootOperations begin/recover in the shared-state writer. Host lifecycle APIs dispatch these operations; remaining host imports from this kernel are pure helpers and constants. |
| **src/infra** · `src/infra/gateway-boot-lifecycle.worker.ts` | 5/1/3/0/0 | 43 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/gateway-owner-lease.worker.ts` | 1/0/0/0/0 | 211 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/heartbeat-outcome-store.kernel.ts` | 2/0/0/0/0 | 17 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/heartbeat-outcome-store.worker.ts` | 1/0/0/0/0 | 34 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/push-apns-store-transaction.ts` | 2/2/0/0/0 | 36 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/push-apns-store-transaction.ts` | 1/4/0/0/0 | 36 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/push-apns-store.ts` | 1/1/0/0/0 | 367 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/push-apns-store.worker.ts` | 2/0/2/0/0 | 64 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/push-web-store.database.worker.ts` | 12/5/6/0/0 | 83 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/restart-lifecycle.worker.ts` | 1/1/0/0/0 | 24 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/restart-sentinel-store.ts` | 2/0/0/0/0 | 386 | Deletion and snapshot reads run in restart-sentinel.worker.ts or restart-sentinel.read.worker.ts; managed-handoff snapshot callers are also worker operations. |
| **src/infra** · `src/infra/restart-sentinel-store.ts` | 2/0/0/0/0 | 380 | Deletion and snapshot reads run in restart-sentinel.worker.ts or restart-sentinel.read.worker.ts; managed-handoff snapshot callers are also worker operations. |
| **src/infra** · `src/infra/session-cost-usage-cache.kernel.ts` | 5/0/0/0/0 | 228 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/session-cost-usage-partitions.worker.ts` | 5/0/0/0/0 | 41 | Worker implementation; keep SQL in this owner |
| **src/infra** · `src/infra/session-cost-usage-refresh-inputs.worker.ts` | 1/0/0/0/3 | 105 | Worker implementation; keep SQL in this owner |

View file

@ -220,7 +220,6 @@ export function registerCodexCompletionCommandTest() {
params.agentId = "main";
params.sessionKey = sessionKey;
params.trigger = "event";
params.continuesConversation = true;
params.abortSignal = controller.signal;
params.execOverrides = { host: "gateway", mode: "full" };
params.messageProvider = route.channel;
@ -287,7 +286,6 @@ export function registerCodexCompletionCommandTest() {
expect(factory.mock.calls[0]?.[0]).toMatchObject({
sessionKey,
trigger: "event",
continuesConversation: true,
});
const launched = await tool("exec").execute("continuation-exec", {
command: "echo codex-chain-ok",

View file

@ -1079,20 +1079,20 @@ it("builds the durable registered-tool superset without loading a provider runti
);
runtimePlan.tools.normalize = planNormalize as typeof runtimePlan.tools.normalize;
params.runtimePlan = runtimePlan;
params.disableMessageTool = true;
const statusTool = createRuntimeDynamicTool("session_status");
const messageTool = createRuntimeDynamicTool("message");
const heartbeatTool = createRuntimeDynamicTool("heartbeat_respond");
const invalidTool = {
...createRuntimeDynamicTool("invalid_registered_tool"),
parameters: { type: "array", items: { type: "string" } },
};
setCodexTestToolFactory(params, (options) => [
messageTool,
...(options?.enableHeartbeatTool === true ? [heartbeatTool, invalidTool] : []),
statusTool,
...(options?.disableMessageTool === true ? [] : [messageTool, invalidTool]),
]);
const turnTools = await buildTools({ sandbox: null as never });
const registeredTools = await buildTools({
forceHeartbeatTool: true,
ignoreDisableMessageTool: true,
ignoreRuntimePlan: true,
sandbox: null as never,
@ -1105,11 +1105,11 @@ it("builds the durable registered-tool superset without loading a provider runti
]);
expect(hoisted.normalizeAgentRuntimeTools.mock.calls[1]?.[0]).not.toHaveProperty("runtimeHandle");
expect(turnTools).toMatchObject([
{ name: "message", description: `turn:${messageTool.description}` },
{ name: "session_status", description: `turn:${statusTool.description}` },
]);
expect(registeredTools).toMatchObject([
{ name: "session_status", description: statusTool.description },
{ name: "message", description: messageTool.description },
{ name: "heartbeat_respond", description: heartbeatTool.description },
]);
});

View file

@ -101,7 +101,6 @@ type DynamicToolBuildParams = {
cronCreatorToolAllowlistCaptureRef?: OpenClawCodingToolsOptions["cronCreatorToolAllowlistCaptureRef"];
resolveCronCreatorToolAuthority?: Parameters<typeof createCodexHostToolSurface>[3];
cronCreatorAuthorityUnavailableReason?: OpenClawCodingToolsOptions["cronCreatorAuthorityUnavailableReason"];
forceHeartbeatTool?: boolean;
ignoreDisableMessageTool?: boolean;
ignoreRuntimePlan?: boolean;
/** Host fact resolver; injectable only for focused plugin contract tests. */
@ -272,8 +271,6 @@ export async function buildDynamicTools(
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool,
forceMessageTool: shouldForceMessageTool(messagePolicyParams),
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
onYield: (message, acknowledgment) => {
input.onYieldDetected(message, acknowledgment);
input.onCodexAppServerEvent?.({
@ -465,7 +462,7 @@ export async function buildDynamicTools(
}
const summary = toolBuildStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(summary, input.profilerEnabled)) {
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
const phase = input.ignoreRuntimePlan ? "registered-tools" : "runtime-tools";
embeddedAgentLog.warn(
`codex app-server dynamic tool build timings runId=${params.runId} sessionId=${params.sessionId} phase=${phase} totalMs=${summary.totalMs} stages=${formatStageTimings(summary.stages)}`,
{
@ -479,7 +476,6 @@ export async function buildDynamicTools(
visionFilteredToolCount: visionFilteredTools.length,
filteredToolCount: filteredTools.length,
normalizedToolCount: exposedTools.length,
forceHeartbeatTool: input.forceHeartbeatTool === true,
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
},

View file

@ -419,7 +419,6 @@ export abstract class CodexTurnProjection {
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
...mediaDelivery,
messagingToolSourceReplyPayloads: toolTelemetry.messagingToolSourceReplyPayloads ?? [],
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
successfulCronAdds: toolTelemetry.successfulCronAdds,
acceptedSessionSpawns: toolTelemetry.acceptedSessionSpawns,

View file

@ -359,8 +359,6 @@ async function buildOpenClawCodingToolsOptions(
a.requireExplicitMessageTarget ?? isSubagentSessionKey(liveSessionKey),
disableMessageTool: a.disableMessageTool,
forceMessageTool: a.forceMessageTool,
enableHeartbeatTool: a.enableHeartbeatTool,
forceHeartbeatTool: a.forceHeartbeatTool,
authProfileStore: a.toolAuthProfileStore ?? a.authProfileStore,
computerContextEpoch: input.computerContextEpoch,
onToolOutcome: a.onToolOutcome,

View file

@ -719,7 +719,6 @@ const sqliteWorkerProtocolModules = new Map<string, ReadonlySet<string>>([
"src/config/sessions/session-sharing-store.worker.ts",
"src/config/sessions/session-transcript-projection-publication.worker.ts",
"src/gateway/worker-environments/transcript-commit.worker.ts",
"src/infra/heartbeat-outcome-store.worker.ts",
"src/session-cards/progress-card-store.worker.ts",
]),
],

View file

@ -185,7 +185,6 @@ const migratedTranscriptWriterFiles = new Set([
const migratedSessionLifecycleCleanupFiles = new Set([
"src/config/sessions/cleanup-service.ts",
"src/cron/session-reaper.ts",
"src/infra/heartbeat-runner.ts",
]);
export const readOnlyGatewaySessionAccessorFiles = new Set([

View file

@ -3442,7 +3442,6 @@ const workerModules = new Set([
"src/config/sessions/session-sharing-store.native.ts", // Membership mutations run only in session-sharing-store.worker.ts; host imports are type-only.
"src/config/sessions/session-accessor.sqlite-participants.native.ts", // Participant mutation SQL runs only through the sharing worker; host imports are type-only.
"src/config/sessions/session-group-categories.read.ts", // Category enumeration runs only in session-sharing-store.worker.ts.
"src/infra/heartbeat-outcome-store.kernel.ts", // Outcome mutations run only in heartbeat-outcome-store.worker.ts.
"src/config/sessions/session-pending-input-history.kernel.ts", // History-read worker owns all remaining callers.
"src/config/sessions/session-pending-input-history-reconcile.ts", // Pending-input execution registry dispatches only in openclaw-agent-execution.worker.ts.
"src/infra/session-cost-usage-cache-read.ts", // Usage-cache reads run only in session-transcript.worker.ts.

View file

@ -384,7 +384,6 @@ const COMPACT_NODE_TEST_OWNER_RUNNERS = new Map([
["agentic-control-plane-http-plugin-ws", DEFAULT_NODE_TEST_RUNNER],
["agentic-control-plane-runtime-ui-tools", DEFAULT_NODE_TEST_RUNNER],
["auto-reply-reply-agent-runner", DEFAULT_NODE_TEST_RUNNER],
["core-runtime-infra-heartbeat-runner", DEFAULT_NODE_TEST_RUNNER],
["core-runtime-infra-storage-state", DEFAULT_NODE_TEST_RUNNER],
["core-runtime-infra-system-runtime", DEFAULT_NODE_TEST_RUNNER],
]),
@ -554,7 +553,6 @@ const COMPACT_SECONDS_HINTS: readonly [
["core-runtime-infra-gateway-processes", 1, undefined, 1],
["core-runtime-infra-gateway-watch", 1, undefined, 1],
["core-runtime-infra-heartbeat-core", 7, undefined, 10],
["core-runtime-infra-heartbeat-runner", 59, undefined, 106],
["core-runtime-infra-misc", 14, undefined, 33],
["core-runtime-infra-misc-dedupe-disk", 1, undefined, 1],
["core-runtime-infra-misc-os", 1, undefined, 1],
@ -1889,10 +1887,9 @@ function createCronSplitShards(): NodeTestSplitShard[] {
.filter((shard) => shard.includePatterns.length > 0);
}
// Keep precedence: specific owners such as heartbeat-runner precede their broad prefixes.
// Keep precedence: specific owners precede their broad prefixes.
const INFRA_SHARD_PREFIXES = [
["approval-exec", ["approval", "exec"]],
["heartbeat-runner", ["heartbeat-runner"]],
["heartbeat-core", ["heartbeat"]],
["outbound-actions", ["outbound/message-action"]],
["outbound-core", ["outbound/"]],
@ -2026,7 +2023,6 @@ function createInfraSplitShards(): NodeTestSplitShard[] {
"core-runtime-infra-gateway-processes",
"core-runtime-infra-gateway-watch",
"core-runtime-infra-heartbeat-core",
"core-runtime-infra-heartbeat-runner",
"core-runtime-infra-misc",
"core-runtime-infra-misc-dedupe-disk",
"core-runtime-infra-misc-os",

View file

@ -1088,19 +1088,6 @@ const policyTestWatches: readonly PolicyTestWatch[] = [
"src/state/openclaw-state.worker.ts",
],
},
...[
"src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts",
"src/infra/heartbeat-runner.committed-work.test.ts",
"src/infra/heartbeat-runner.isolated-session-mirror.test.ts",
"src/infra/heartbeat-runner.typing.test.ts",
].map((testFile): PolicyTestWatch => ({
testFile,
watchGlobs: [
"src/config/sessions/session-transcript-projection-publication.worker.ts",
"src/config/sessions/session-transcript.worker.ts",
"src/state/openclaw-agent-execution.worker.ts",
],
})),
...[
"src/infra/outbound/conversation-delivery.queue-admission.test.ts",
"src/infra/outbound/delivery-queue-entry-state.test.ts",

View file

@ -3166,24 +3166,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/infra/fs-safe-import-boundary.test.ts",
"src/infra/gateway-lock.test.ts",
"src/infra/heartbeat-agent-resolution.test.ts",
"src/infra/heartbeat-outcome-store.test.ts",
"src/infra/heartbeat-runner.ack-token-heartbeat-acks.test.ts",
"src/infra/heartbeat-runner.event-routing.test.ts",
"src/infra/heartbeat-runner.identity.test.ts",
"src/infra/heartbeat-runner.isolated-key-stability.test.ts",
"src/infra/heartbeat-runner.isolated-session-mirror.test.ts",
"src/infra/heartbeat-runner.model-override.test.ts",
"src/infra/heartbeat-runner.next-user-outcomes.test.ts",
"src/infra/heartbeat-runner.response-prefix-template.test.ts",
"src/infra/heartbeat-runner.returns-default-unset.test.ts",
"src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts",
"src/infra/heartbeat-runner.session-created.test.ts",
"src/infra/heartbeat-runner.skips-busy-session-lane.test.ts",
"src/infra/heartbeat-runner.stale-exec.test.ts",
"src/infra/heartbeat-runner.subagent-session-guard.test.ts",
"src/infra/heartbeat-runner.tool-response.test.ts",
"src/infra/heartbeat-runner.typing.test.ts",
"src/infra/heartbeat-runner.webchat-exec-completion.test.ts",
"src/infra/install-flow.test.ts",
"src/infra/install-package-dir.test.ts",
"src/infra/install-source-utils.chmod.test.ts",
@ -4724,7 +4706,6 @@ const PR_EXEMPT_RUNTIME_TEST_FILES = [
"src/infra/device-pairing-node.test.ts",
"src/infra/file-descriptor.test.ts",
"src/infra/git-worker.lifecycle.test.ts",
"src/infra/heartbeat-runner.structured-delivery.test.ts",
"src/infra/openclaw-cli-shim.test.ts",
"src/infra/outbound/current-conversation-bindings.worker.test.ts",
"src/infra/outbound/deliver-queue.cancellation-integration.test.ts",

View file

@ -431,7 +431,6 @@ src/auto-reply/commands-registry.data.ts
src/auto-reply/commands-registry.shared.ts
src/auto-reply/group-activation.ts
src/auto-reply/heartbeat-filter.ts
src/auto-reply/heartbeat-tool-response.ts
src/auto-reply/heartbeat.ts
src/auto-reply/reply/commands-slash-parse.ts
src/auto-reply/reply/delivery-hints.ts

View file

@ -496,7 +496,7 @@ export function resolveLiveShardPreparation(files: string[]): LiveShardPreparati
if (
files.some(isSourceGatewayLiveTest) ||
files.some((file) => file.startsWith("test/e2e/qa-lab/runtime/")) ||
files.includes("src/infra/heartbeat-runner.live.test.ts") ||
files.includes("src/infra/session-event-wake.live.test.ts") ||
files.includes("src/agents/tools/image-tool.providers.live.test.ts") ||
files.includes("extensions/openai/openai.live.test.ts")
) {

View file

@ -2875,18 +2875,20 @@ rules:
message: Dangerous host env override pivot reaches host env sanitization boundary or policy.
severity: ERROR
languages:
- json
- typescript
- javascript
- json
pattern-either:
- patterns:
- pattern-inside: |
{ ... }
- pattern-regex: '"(GIT_SSH_COMMAND|GIT_EXEC_PATH|GIT_TEMPLATE_DIR|YARN_RC_FILENAME|CC|CXX|CMAKE_C_COMPILER|CMAKE_CXX_COMPILER|RUSTC_WRAPPER|CARGO_BUILD_RUSTC|CARGO_BUILD_RUSTC_WRAPPER|MAKEFLAGS|MFLAGS)"'
- pattern-not-inside: resolveEnvironmentValue(...)
- patterns:
- pattern-inside: |
{ ... }
- pattern-regex: '"(GIT_CONFIG_[A-Z0-9_]+|NPM_CONFIG_[A-Z0-9_]+|CARGO_REGISTRIES_[A-Z0-9_]+|TF_VAR_[A-Z0-9_]+)"'
- pattern-not-inside: resolveEnvironmentValue(...)
- patterns:
- pattern-either:
- pattern: |
@ -2902,6 +2904,8 @@ rules:
detector-bucket: precise
source-run: 2026-04-17T07-37-10Z
source-rule-id: openclaw-dangerous-host-env-override-pivots
advisory-id: GHSA-J425-WHC4-4JGC
source-file: security/opengrep/rules/ghsa-j425-whc4-4jgc/host-env-overrides.yml
- id: ghsa-jf25-7968-h2h5.openclaw-nodes-screen-record-outpath-guard-missing
message: The nodes tool (which contains screen_record) must be wrapped via applyNodesToolWorkspaceGuard, which adds outPath to the pathParamKeys. Without this, screen_record outPath bypasses the workspace boundary. See GHSA-JF25-7968-H2H5.
severity: ERROR

View file

@ -0,0 +1,38 @@
// Static scanner fixtures; never executed.
declare const inheritedEnv: Record<string, string>;
declare function resolveEnvironmentValue(env: Record<string, string>, key: string): string;
declare function sanitizeHostExecEnv(input: unknown): unknown;
declare function sanitizeSystemRunEnvOverrides(input: unknown): unknown;
const inheritedValues = {
// ok: openclaw-dangerous-host-env-override-pivots
git: resolveEnvironmentValue(inheritedEnv, "GIT_CONFIG_PARAMETERS"),
// ok: openclaw-dangerous-host-env-override-pivots
ssh: resolveEnvironmentValue(inheritedEnv, "GIT_SSH_COMMAND"),
};
const directOverrides = {
// ruleid: openclaw-dangerous-host-env-override-pivots
["GIT_CONFIG_PARAMETERS"]: "untrusted",
// ruleid: openclaw-dangerous-host-env-override-pivots
["GIT_SSH_COMMAND"]: "untrusted",
};
const copiedOverride = {
// ruleid: openclaw-dangerous-host-env-override-pivots
["GIT_CONFIG_PARAMETERS"]: resolveEnvironmentValue(inheritedEnv, "GIT_CONFIG_PARAMETERS"),
};
// ruleid: openclaw-dangerous-host-env-override-pivots
sanitizeHostExecEnv({ overrides: { GIT_CONFIG_PARAMETERS: "untrusted" } });
// ruleid: openclaw-dangerous-host-env-override-pivots
sanitizeSystemRunEnvOverrides({ overrides: { GIT_SSH_COMMAND: "untrusted" } });
// ruleid: openclaw-dangerous-host-env-override-pivots
sanitizeHostExecEnv({
overrides: {
GIT_CONFIG_PARAMETERS: resolveEnvironmentValue(inheritedEnv, "GIT_CONFIG_PARAMETERS"),
},
});
export { inheritedValues, directOverrides, copiedOverride };

View file

@ -0,0 +1,36 @@
rules:
- id: openclaw-dangerous-host-env-override-pivots
message: Dangerous host env override pivot reaches host env sanitization boundary or policy.
severity: ERROR
# OpenGrep test parses mixed-language fixtures with the first language.
languages:
- typescript
- javascript
- json
pattern-either:
- patterns:
- pattern-inside: |
{ ... }
- pattern-regex: '"(GIT_SSH_COMMAND|GIT_EXEC_PATH|GIT_TEMPLATE_DIR|YARN_RC_FILENAME|CC|CXX|CMAKE_C_COMPILER|CMAKE_CXX_COMPILER|RUSTC_WRAPPER|CARGO_BUILD_RUSTC|CARGO_BUILD_RUSTC_WRAPPER|MAKEFLAGS|MFLAGS)"'
# Reading an inherited value is not an override; enclosing keys still match.
- pattern-not-inside: resolveEnvironmentValue(...)
- patterns:
- pattern-inside: |
{ ... }
- pattern-regex: '"(GIT_CONFIG_[A-Z0-9_]+|NPM_CONFIG_[A-Z0-9_]+|CARGO_REGISTRIES_[A-Z0-9_]+|TF_VAR_[A-Z0-9_]+)"'
- pattern-not-inside: resolveEnvironmentValue(...)
- patterns:
- pattern-either:
- pattern: |
sanitizeHostExecEnv({ ..., overrides: { ..., $KEY: $VALUE, ... }, ... })
- pattern: |
sanitizeSystemRunEnvOverrides({ ..., overrides: { ..., $KEY: $VALUE, ... }, ... })
- metavariable-regex:
metavariable: $KEY
regex: ^(GIT_SSH_COMMAND|GIT_EXEC_PATH|GIT_TEMPLATE_DIR|YARN_RC_FILENAME|CC|CXX|CMAKE_C_COMPILER|CMAKE_CXX_COMPILER|RUSTC_WRAPPER|CARGO_BUILD_RUSTC|CARGO_BUILD_RUSTC_WRAPPER|MAKEFLAGS|MFLAGS|GIT_CONFIG_[A-Z0-9_]+|NPM_CONFIG_[A-Z0-9_]+|CARGO_REGISTRIES_[A-Z0-9_]+|TF_VAR_[A-Z0-9_]+)$
metadata:
ghsa: GHSA-J425-WHC4-4JGC
advisory-url: https://github.com/openclaw/openclaw/security/advisories/GHSA-J425-WHC4-4JGC
detector-bucket: precise
source-run: 2026-04-17T07-37-10Z
source-rule-id: openclaw-dangerous-host-env-override-pivots

View file

@ -9,19 +9,16 @@ describe("prepareAgentRunTerminalReply", () => {
{
name: "an acknowledgement-only message",
payload: { text: "HEARTBEAT_OK" },
isHeartbeatTrigger: false,
visible: false,
},
{
name: "visible text beside the acknowledgement",
payload: { text: "HEARTBEAT_OK Everything is ready." },
isHeartbeatTrigger: false,
visible: true,
},
{
name: "media beside the acknowledgement",
payload: { text: "HEARTBEAT_OK", mediaUrl: "https://example.invalid/chart.png" },
isHeartbeatTrigger: false,
visible: true,
},
{
@ -31,20 +28,12 @@ describe("prepareAgentRunTerminalReply", () => {
mediaUrl: " ",
mediaUrls: [" ", "https://example.invalid/chart.png"],
},
isHeartbeatTrigger: false,
visible: true,
},
{
name: "a real heartbeat acknowledgement",
payload: { text: "HEARTBEAT_OK" },
isHeartbeatTrigger: true,
visible: true,
},
])("projects $name through message presentation", ({ payload, isHeartbeatTrigger, visible }) => {
])("projects $name through message presentation", ({ payload, visible }) => {
expect(
filterAgentRunTerminalPayloadsForPresentation({
payloads: [payload],
isHeartbeatTrigger,
}),
).toEqual(visible ? [payload] : []);
});

View file

@ -38,9 +38,8 @@ export type PreparedAgentRunTerminalReply = {
/** Projects payloads through the shared message-presentation acknowledgement rule. */
export function filterAgentRunTerminalPayloadsForPresentation(params: {
payloads: ReplyPayload[] | undefined;
isHeartbeatTrigger: boolean;
}): ReplyPayload[] | undefined {
if (params.isHeartbeatTrigger || !params.payloads) {
if (!params.payloads) {
return params.payloads;
}
return params.payloads.filter((payload) => {

View file

@ -879,16 +879,6 @@ describe("createOpenClawCodingTools", () => {
}
});
it("enables heartbeat response when visible replies are message-tool-only", () => {
expectToolSurface(
{
config: { messages: { visibleReplies: "message_tool" }, tools: { profile: "coding" } },
trigger: "heartbeat",
},
["heartbeat_respond"],
);
});
it("can keep message available when a cron route needs it under a provider coding profile", () => {
const options = {
config: { tools: { byProvider: { openai: { profile: "coding" } } } },

View file

@ -56,8 +56,6 @@ export type OpenClawCodingToolsOptions = {
trace?: DiagnosticTraceContext;
/** What initiated this run (for trigger-specific tool restrictions). */
trigger?: string;
/** Heartbeat-transported turn that continues a conversation (its own command completion). */
continuesConversation?: boolean;
/** Stable cron job identifier populated for cron-triggered runs. */
jobId?: string;
/** Relative workspace path that memory-triggered writes may append to. */
@ -106,8 +104,6 @@ export type OpenClawCodingToolsOptions = {
skillInstructionDeliveryCache?: SkillInstructionDeliveryCache;
/** Keep the message tool available even when the selected profile omits it. */
forceMessageTool?: boolean;
/** Keep the heartbeat response tool available even when the selected profile omits it. */
forceHeartbeatTool?: boolean;
/** If false, build plugin tools only while preserving the shared policy pipeline. */
includeCoreTools?: boolean;
/** Include Tool Search control tools when enabled for this run. */

View file

@ -76,13 +76,11 @@ it.each<[string, ToolOptions]>([
],
["media completion", completionTurn("image_generate")],
[
"heartbeat",
"scheduled",
{
...session,
runId: "run-heartbeat",
trigger: "heartbeat",
enableHeartbeatTool: true,
forceHeartbeatTool: true,
runId: "run-scheduled",
trigger: "cron",
sourceReplyDeliveryMode: "message_tool_only",
},
],

View file

@ -12,7 +12,6 @@
* Tool factories and policy modules implement each rule; this module orders
* their application. Sync and async callers share the same assembly.
*/
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js";
import { messageToolOwnsVisibleReply } from "../auto-reply/source-reply-delivery-mode.js";
import { createRuntimeConfigReader } from "../config/runtime-snapshot.js";
import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing.js";
@ -101,7 +100,6 @@ import {
TOOL_SEARCH_RAW_TOOL_NAME,
} from "./tool-search.js";
import { replaceWithEffectiveCronCreatorToolAllowlist } from "./tools/cron-tool.js";
import { resolveHeartbeatTools } from "./tools/heartbeat-response-tool.js";
import { prepareSessionPortalToolAccess } from "./tools/session-portal-target.js";
export { resolveToolLoopDetectionConfig } from "./tool-loop-detection-config.js";
@ -146,7 +144,6 @@ function* assembleOpenClawCodingTools(
const executionSessionKey = options?.runSessionKey ?? options?.sessionKey;
const attachmentReadRoot = subagentAttachmentRootForRun(executionAgentId, executionSessionKey);
const { enableHeartbeatTool, offerHeartbeatTool } = resolveHeartbeatTools(options, agentId);
const toolSearchConfig = resolveToolSearchConfig(options?.config);
const toolSearchControlsEnabled =
options?.includeToolSearchControls === true && toolSearchConfig.enabled;
@ -172,7 +169,6 @@ function* assembleOpenClawCodingTools(
const runtimeProfileAlsoAllow = [
...(options && messageToolOwnsVisibleReply(options) ? ["message"] : []),
...(runtimeToolAllowlistIncludesMessage ? ["message"] : []),
...(offerHeartbeatTool ? [HEARTBEAT_RESPONSE_TOOL_NAME] : []),
...toolSearchControlAllowlist,
];
const sandboxWorkspaceMediaReadAllowed = isConversationToolAllowed(capabilityProfile, "read");
@ -264,7 +260,6 @@ function* assembleOpenClawCodingTools(
reviewer: options?.exec?.reviewer ?? execConfig.reviewer,
reviewTranscript: options?.exec?.reviewTranscript,
trigger: options?.trigger,
continuesConversation: options?.continuesConversation,
node: options?.exec?.node ?? execConfig.node,
pathPrepend: mergeGatewayAgentCliPath(
options?.exec?.pathPrepend ?? execConfig.pathPrepend,
@ -472,8 +467,6 @@ function* assembleOpenClawCodingTools(
: (options?.computerTransport ??
resolveSessionPlacementComputer(options?.operationalRunInstance)),
sourceReplyOnly,
enableHeartbeatTool,
offerHeartbeatTool,
disablePluginTools: !includePluginTools,
wrapBeforeToolCallHook: false,
...(cronSelfRemoveOnlyJobId ? { cronSelfRemoveOnlyJobId } : {}),

View file

@ -182,9 +182,6 @@ export function resolveExecNotificationDefaults(defaults?: ExecToolDefaults) {
notifySessionKey,
resolveSubagentSession,
notifyDeliveryContext,
// Preserve conversation provenance for passive subagent completion notices.
notifyFromConversationTurn:
defaults?.trigger === "user" || defaults?.continuesConversation === true,
};
}

View file

@ -158,7 +158,6 @@ export function createExecTool(defaults?: ExecToolDefaults) {
notifySessionKey,
resolveSubagentSession,
notifyDeliveryContext,
notifyFromConversationTurn,
} = resolveExecNotificationDefaults(defaults);
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
// Derive agentId only when sessionKey is an agent session key.
@ -571,7 +570,6 @@ export function createExecTool(defaults?: ExecToolDefaults) {
agentId,
eventRouting: defaults?.eventRouting,
notifyDeliveryContext,
notifyFromConversationTurn,
timeoutSec: effectiveTimeout,
processContinuationAvailable: allowBackground,
startupSignal: signal,

View file

@ -63,7 +63,6 @@ export type ExecToolDefaults = ExecToolRoutingDefaults & {
mode?: ExecMode;
bypassHostApprovalFloors?: boolean;
trigger?: string;
continuesConversation?: boolean;
pathPrepend?: string[];
safeBins?: string[];
strictInlineEval?: boolean;

View file

@ -2,7 +2,6 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect, it, vi } from "vitest";
import { persistHeartbeatOutcome } from "../infra/heartbeat-outcome-store.js";
import { createDeferredCore } from "../shared/deferred.js";
import { registerOpenClawAgentDatabaseAsyncResource } from "../state/openclaw-agent-db-resources.js";
import { closeOpenClawAgentDatabaseByPathAsync } from "../state/openclaw-agent-db.js";
@ -20,13 +19,6 @@ it("drains owned agent resources before removing preparation directories and pre
dir,
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: dir } }),
}));
await persistHeartbeatOutcome({
...first.sessionTarget,
env: { OPENCLAW_STATE_DIR: first.dir },
runSessionKey: "agent:main:main:heartbeat",
occurredAt: 100,
response: { outcome: "progress", notify: false, summary: "Fixture-owned worker" },
});
const entered = createDeferredCore();
const release = createDeferredCore();
let resourceClosed = false;

View file

@ -205,13 +205,6 @@ it.each([
trigger: "user" as const,
expectedError: false,
},
{
name: "a real heartbeat acknowledgement",
terminalReplyExpectation: "result-required" as const,
output: { text: "HEARTBEAT_OK" },
trigger: "heartbeat" as const,
expectedError: false,
},
])("settles $name at the message-presentation boundary", (testCase) => {
const context = buildPreparedCliRunContext({ provider: "claude-cli" });
context.params.terminalReplyExpectation = testCase.terminalReplyExpectation;

View file

@ -475,7 +475,6 @@ export function buildCliRunResult(params: {
const replyExpectation = resolveReplyExpectation(runParams);
const payloadsForVisibleCompletion = filterAgentRunTerminalPayloadsForPresentation({
payloads: payloadsWithToolMedia,
isHeartbeatTrigger: runParams.trigger === "heartbeat" && !runParams.continuesConversation,
});
const unflushed = !sessionBindingDisabled && effectiveCliSessionId && bindingFlushOk === false;
const terminalInterruption = output.terminalInterruption;

View file

@ -276,7 +276,6 @@ export function buildCliMcpGrantContext(params: {
...(execOverrides ? { execOverrides } : {}),
...(bashElevated ? { bashElevated } : {}),
...(params.run.trigger ? { trigger: params.run.trigger } : {}),
...(params.run.continuesConversation ? { continuesConversation: true } : {}),
...(normalizeOptionalString(params.run.approvalReviewerDeviceId)
? { approvalReviewerDeviceId: params.run.approvalReviewerDeviceId?.trim() }
: {}),

View file

@ -40,10 +40,6 @@ import {
import type { ContextEngine } from "../../context-engine/types.js";
import type { resolveMcpLoopbackScopedTools as resolveLoopbackTools } from "../../gateway/mcp-http.runtime.js";
import { setActiveNodeContexts } from "../../infra/active-node-context.js";
import {
claimHeartbeatOutcomeForRun,
persistHeartbeatOutcome,
} from "../../infra/heartbeat-outcome-store.js";
import { labelRuntimeContextText } from "../../llm/types.js";
import { CliBackendAuthProfilePreparationError } from "../../plugins/cli-backend-errors.js";
import type {
@ -692,140 +688,13 @@ describe("prepareCliRunContext", () => {
clearRuntimeConfigSnapshot();
});
it.each(["process", "plugin"] as const)(
"carries silent heartbeat outcome in late %s user input without rebinding",
async (targetKind) => {
const { sessionTarget } = fixture.session;
if (targetKind === "plugin") {
setRawCliBackendForPrepareTest({
...defaultTestCliBackend,
prepareExecution: () => ({
async *execute() {
yield { type: "result" };
},
}),
});
}
const admission = prepareSystemAgentRunAdmission(
{},
"run-test",
"main",
"heartbeat-context-test",
);
const input = {
preparedRunAdmission: admission,
sessionKey: sessionTarget.sessionKey,
trigger: "user" as const,
prompt: "What happened?",
transcriptPrompt: "What happened?",
currentInboundContext: { text: "Quoted reply", resumableText: "Room delta" },
};
try {
const before = await fixture.prepare(input);
await persistHeartbeatOutcome({
...sessionTarget,
runSessionKey: "agent:main:main:heartbeat",
occurredAt: 1,
response: { outcome: "done", notify: false, summary: "ISOLATED_CLI_OUTCOME_947" },
});
for (let retry = 0; retry < 2; retry++) {
const context = await fixture.prepare(input);
expect(context.executionTarget.kind).toBe(targetKind);
const visibleInput = [
context.params.prompt,
context.promptContext?.prependContext,
context.promptContext?.appendContext,
]
.filter(Boolean)
.join("\n");
expect(visibleInput.match(/ISOLATED_CLI_OUTCOME_947/g)).toHaveLength(1);
expect(context.params.transcriptPrompt).toBe("What happened?");
expect(context.systemPrompt).toBe(before.systemPrompt);
expect(context.extraSystemPromptHash).toBe(before.extraSystemPromptHash);
expect(context.messageToolPolicyHash).toBe(before.messageToolPolicyHash);
expect(input.currentInboundContext).toEqual({
text: "Quoted reply",
resumableText: "Room delta",
});
}
const laterAdmission = prepareSystemAgentRunAdmission(
{},
"later-user-run",
"main",
"heartbeat-context-test",
);
try {
const later = await fixture.prepare({
...input,
runId: "later-user-run",
preparedRunAdmission: laterAdmission,
});
expect(JSON.stringify([later.params.prompt, later.promptContext])).not.toContain(
"ISOLATED_CLI_OUTCOME_947",
);
} finally {
laterAdmission.close();
}
} finally {
admission.close();
}
},
);
it.each(["heartbeat", "in-memory", "aborted"] as const)(
"does not consume silent heartbeat context for %s CLI preparation",
async (kind) => {
const { sessionTarget, dir } = fixture.session;
await persistHeartbeatOutcome({
...sessionTarget,
runSessionKey: "agent:main:main:heartbeat",
occurredAt: 1,
response: { outcome: "done", notify: false, summary: "Retained CLI outcome" },
});
const admission = prepareSystemAgentRunAdmission(
{},
"run-test",
"main",
"heartbeat-context-test",
);
const input = {
preparedRunAdmission: admission,
sessionKey: sessionTarget.sessionKey,
trigger: kind === "heartbeat" ? kind : ("user" as const),
...(kind === "in-memory" ? { sessionManager: SessionManager.inMemory(dir) } : {}),
...(kind === "aborted" ? { abortSignal: AbortSignal.abort() } : {}),
};
try {
if (kind === "aborted") {
await expect(fixture.prepare(input)).rejects.toThrow();
} else {
const context = await fixture.prepare(input);
expect(JSON.stringify([context.params.prompt, context.promptContext])).not.toContain(
"Retained CLI outcome",
);
}
expect(
(await claimHeartbeatOutcomeForRun({ ...sessionTarget, runId: "next-user" }))?.summary,
).toBe("Retained CLI outcome");
} finally {
admission.close();
}
},
);
it("does not renew an explicitly revoked CLI owner to claim silent heartbeat context", async () => {
it("does not renew an explicitly revoked CLI owner during preparation", async () => {
const { sessionTarget } = fixture.session;
await persistHeartbeatOutcome({
...sessionTarget,
runSessionKey: "agent:main:main:heartbeat",
occurredAt: 1,
response: { outcome: "done", notify: false, summary: "Keep revoked-owner outcome" },
});
const admission = prepareSystemAgentRunAdmission(
{},
"revoked-user",
"main",
"heartbeat-context-test",
"revoked-context-test",
);
const admittedRunContext = await admission.admit("embedded");
admission.close();
@ -837,9 +706,6 @@ describe("prepareCliRunContext", () => {
sessionKey: sessionTarget.sessionKey,
}),
).rejects.toThrow("authority");
expect(
(await claimHeartbeatOutcomeForRun({ ...sessionTarget, runId: "next-user" }))?.summary,
).toBe("Keep revoked-owner outcome");
});
it("honors an explicit auth agent directory independently of session identity", async () => {

View file

@ -27,7 +27,6 @@ import {
resolveMcpLoopbackPolicyTools,
resolveMcpLoopbackScopedTools,
} from "../../gateway/mcp-http.runtime.js";
import { claimHeartbeatContextForUserRun } from "../../infra/heartbeat-outcome-store.js";
import { buildSystemAgentToolsMcpServerConfig } from "../../mcp/openclaw-tools-serve-config.js";
import { CliBackendAuthProfilePreparationError } from "../../plugins/cli-backend-errors.js";
import type {
@ -146,7 +145,6 @@ import { runWithCliPreparationSource } from "./prepare-source.js";
import { resolveCliRuntimeToolPolicy } from "./prepare-tool-policy.js";
import {
buildCliVisibleReplyGuidance,
composeCliPromptContext,
createCliCurrentPromptRenderer,
prepareCliSystemPrompt,
prepareCliTurnPromptContext,
@ -1592,7 +1590,7 @@ async function prepareCliRunContextWithinReadFence(
// CLI sessions still receive prior conversation context via stdin.
const shouldPrepareOpenClawHistoryPrompt =
!skipsTurnPreparation && (!reusableCliSessionId || allowRawTranscriptReseed);
let openClawHistoryPrompt = shouldPrepareOpenClawHistoryPrompt
const openClawHistoryPrompt = shouldPrepareOpenClawHistoryPrompt
? buildCliSessionHistoryPrompt({
messages: sessionPromptContext?.reseedMessages ?? [],
prompt: historyPromptCurrentTurn,
@ -1782,34 +1780,7 @@ async function prepareCliRunContextWithinReadFence(
});
}
const hadSessionFile = await hasCliSessionTranscript(params);
let preparedParams = await admitFinalParams();
const note = await claimHeartbeatContextForUserRun({
...preparedParams,
agentId: sessionAgentId,
storePath: params.sessionTarget?.storePath ?? params.storePath,
detached: Boolean(params.sessionManager || params.isolatedCompletion),
assertCurrent: createCliRunCurrentAssertion(preparedParams),
});
if (note) {
preparedParams = {
...preparedParams,
transcriptPrompt: finalizedTranscriptPrompt ?? params.prompt,
};
const append = (text: string) => composeCliPromptContext(text, { appendContext: note });
if (executionTarget.kind === "plugin") {
promptContext = {
...promptContext,
appendContext: append(promptContext?.appendContext ?? ""),
};
promptForHooks = append(promptForHooks ?? preparedParams.prompt);
} else {
preparedParams.prompt = append(preparedParams.prompt);
if (openClawHistoryPrompt) {
openClawHistoryPrompt = append(openClawHistoryPrompt);
}
}
}
const preparedParams = await admitFinalParams();
return finishPreparation({
...buildPreparedContext(preparedParams),
...(managedClaudeLiveSessionGeneration

View file

@ -51,7 +51,6 @@ export type RunCliAgentParams = Pick<
| "conversationToolPolicy"
| "trustedInternalHandoff"
| "trigger"
| "continuesConversation"
| "finalizePromptForResolvedTools"
| "currentInboundContext"
| "runtimeContextFragments"

View file

@ -200,37 +200,6 @@ describe("runEmbeddedAgent silent-error retry", () => {
});
});
it("does not mark incomplete turns fallback-safe after a terminal heartbeat response", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
...emptyErrorAttempt("anthropic", "claude-opus-4-8", 1120, [
{
type: "thinking",
thinking: "internal reasoning before provider error",
thinkingSignature: JSON.stringify({ id: "rs_heartbeat_error", type: "reasoning" }),
},
]),
heartbeatToolResponse: {
outcome: "progress",
notify: false,
summary: "Still working",
},
}),
);
const result = await runEmbeddedAgent({
...createOverflowRunParams(state),
provider: "anthropic",
model: "claude-opus-4-8",
runId: "run-terminal-heartbeat-not-fallback-safe",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.meta.error).toMatchObject({
kind: "incomplete_turn",
fallbackSafe: false,
});
});
describe("current-assistant provenance", () => {
it.each([
{ failure: "assistant error", accepted: false },

View file

@ -67,8 +67,6 @@ export function buildEmbeddedForegroundPromptContext(
contextWindow: run.contextWindow,
promptMode: run.promptMode,
forceMessageTool: run.forceMessageTool,
enableHeartbeatTool: run.enableHeartbeatTool,
forceHeartbeatTool: run.forceHeartbeatTool,
allowGatewaySubagentBinding: run.allowGatewaySubagentBinding,
extraSystemPrompt: run.extraSystemPrompt,
gitCoauthorPrompt: run.gitCoauthorPrompt,

View file

@ -39,7 +39,6 @@ export function copyAttemptDeliveryState(
previous.messagingToolSourceReplyPayloads,
)
: attempt.messagingToolSourceReplyPayloads,
heartbeatToolResponse: attempt.heartbeatToolResponse ?? previous?.heartbeatToolResponse,
successfulCronAdds: previous?.successfulCronAdds
? previous.successfulCronAdds + (attempt.successfulCronAdds ?? 0)
: attempt.successfulCronAdds,

View file

@ -99,7 +99,6 @@ export function finalizeEmbeddedAttempt(
result.didSendDeterministicApprovalPrompt === true ||
hasCommittedMessagingToolDeliveryEvidence(result) ||
hasAcceptedSessionSpawn(result.acceptedSessionSpawns) ||
result.heartbeatToolResponse !== undefined ||
(result.clientToolCalls?.length ?? 0) > 0 ||
result.yieldDetected === true ||
result.lastToolError !== undefined ||

View file

@ -1,11 +1,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { AssistantMessageEventStream, type ToolCall } from "openclaw/plugin-sdk/llm";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../../test/helpers/promise.js";
import { selectHeartbeatToolResponse } from "../../../auto-reply/heartbeat-tool-response.js";
import { getReplyPayloadMetadata } from "../../../auto-reply/reply-payload.js";
import { HEARTBEAT_TOKEN } from "../../../auto-reply/tokens.js";
import { Agent } from "../../../plugin-sdk/agent-core.js";
import { createHookRunner } from "../../../plugins/hooks.js";
import { createSubscribedSessionHarness } from "../../embedded-agent-subscribe.e2e-harness.js";
@ -13,7 +9,7 @@ import { makeAssistantMessageFixture } from "../../test-helpers/assistant-messag
import { makeProviderModelFixture } from "../../test-helpers/provider-model-fixture.js";
import { getCoreTtsAttemptResultMediaUrls } from "../../tools/tts-tool-result-provenance.js";
import { recordEmbeddedToolReceipt } from "../tool-send-receipts.js";
import { completeEmbeddedAttemptResult, createAttemptCarryover } from "./attempt-result.js";
import { completeEmbeddedAttemptResult } from "./attempt-result.js";
import { createSubscriptionMock } from "./attempt-spawn-workspace.subscription-mock.test-support.js";
import { resolveSettledToolTerminalContinuation } from "./incomplete-turn-recovery.js";
import { buildPayloads } from "./payloads.test-helpers.js";
@ -26,7 +22,6 @@ type ResultInput = {
terminal?: EmbeddedRunAttemptResult["terminal"];
currentAttemptCompletedAssistant?: EmbeddedRunAttemptResult["currentAttemptCompletedAssistant"];
hasSuccessfulModelResponse?: boolean;
heartbeatToolResponse?: EmbeddedRunAttemptResult["heartbeatToolResponse"];
messagesSnapshot?: EmbeddedRunAttemptResult["messagesSnapshot"];
latestMcpAppChannelView?: EmbeddedRunAttemptResult["latestMcpAppChannelView"];
toolMetas?: EmbeddedRunAttemptResult["toolMetas"];
@ -81,7 +76,6 @@ function createResultFixture(params: ResultInput = {}) {
answerSegments: undefined,
assistantTexts: [...(params?.assistantTexts ?? [])],
didSendViaMessagingTool: () => params?.didSendViaMessagingTool ?? false,
getHeartbeatToolResponse: () => params?.heartbeatToolResponse,
getLatestMcpAppChannelView: () => params?.latestMcpAppChannelView,
getMessagingToolSentMediaUrls: () => params?.messagingToolSentMediaUrls ?? [],
getPendingToolMediaReply: () => params?.pendingToolMediaReply,
@ -133,14 +127,6 @@ function completeResult(params?: ResultInput) {
return completeFixture(createResultFixture(params));
}
function heartbeatPayloads(result: ReturnType<typeof completeResult>) {
return buildPayloads({
isHeartbeatTrigger: true,
assistantTexts: result.assistantTexts,
heartbeatToolResponse: result.heartbeatToolResponse,
});
}
function textAssistant(
text: string,
overrides: Parameters<typeof makeAssistantMessageFixture>[0] = {},
@ -407,57 +393,6 @@ describe("attempt result projection", () => {
});
});
it.each([false, true])("keeps heartbeat carryover scoped to its run (fresh=%s)", (fresh) => {
const carryover = createAttemptCarryover();
const publicResponse = {
outcome: "done" as const,
notify: fresh,
summary: "The task reached its completion condition.",
notificationText: "The monitored task is complete.",
};
const scratch = "Private monitor notes: completion checked; no follow-up needed.";
const providerFailure = {
kind: "failed" as const,
source: "prompt" as const,
error: Object.assign(new Error("529 overloaded"), { status: 529 }),
};
carryover.apply(
completeResult({
heartbeatToolResponse: { ...publicResponse, scratch },
terminal: fresh ? { kind: "ok" } : providerFailure,
}),
);
const completed = completeResult({
assistantTexts: [fresh ? "Still running." : "Retry fallback."],
});
if (fresh) {
createAttemptCarryover().apply(completed);
} else {
carryover.apply(completeResult({ terminal: providerFailure }));
carryover.apply(completed);
}
const payloads = heartbeatPayloads(completed);
expect(payloads).toHaveLength(1);
if (fresh) {
expect(payloads[0]?.text).toBe("Still running.");
expect(selectHeartbeatToolResponse(payloads)).toBeUndefined();
expect(
getReplyPayloadMetadata(expectDefined(payloads[0], "fresh-run payload"))
?.heartbeatScratchProposal,
).toBeUndefined();
return;
}
expect(payloads[0]?.text).toBe(HEARTBEAT_TOKEN);
const selected = expectDefined(
selectHeartbeatToolResponse(payloads),
"expected the carried heartbeat response",
);
expect(selected.response).toEqual(publicResponse);
expect(getReplyPayloadMetadata(selected.payload)?.heartbeatScratchProposal).toBe(scratch);
expect(JSON.stringify(payloads)).not.toContain(scratch);
expect(JSON.stringify(payloads)).not.toContain("Retry fallback.");
});
it("keeps completed client tool calls in reserved source order", () => {
expect(
completeResult({

View file

@ -49,16 +49,12 @@ type EmbeddedAttemptSubscription = ReturnType<typeof subscribeEmbeddedAgentSessi
export function createAttemptCarryover() {
let latestMcpAppChannelView: EmbeddedRunAttemptResult["latestMcpAppChannelView"];
let latestMcpConnectAction: EmbeddedRunAttemptResult["latestMcpConnectAction"];
let heartbeatToolResponse: EmbeddedRunAttemptResult["heartbeatToolResponse"];
let modelAttempt: AgentRuntimeModelAttempt | undefined;
return {
apply(
attempt: Pick<
EmbeddedRunAttemptResult,
| "latestMcpAppChannelView"
| "latestMcpConnectAction"
| "heartbeatToolResponse"
| "modelAttempt"
"latestMcpAppChannelView" | "latestMcpConnectAction" | "modelAttempt"
>,
): void {
modelAttempt = attempt.modelAttempt;
@ -66,8 +62,6 @@ export function createAttemptCarryover() {
attempt.latestMcpAppChannelView = latestMcpAppChannelView;
latestMcpConnectAction = attempt.latestMcpConnectAction ?? latestMcpConnectAction;
attempt.latestMcpConnectAction = latestMcpConnectAction;
heartbeatToolResponse = attempt.heartbeatToolResponse ?? heartbeatToolResponse;
attempt.heartbeatToolResponse = heartbeatToolResponse;
},
get modelAttempt() {
return modelAttempt;

View file

@ -64,7 +64,6 @@ export function createSubscriptionMock(): SubscriptionMock {
getSourceReplyDelivered: () => undefined,
getSourceReplyDeliveryState: () => undefined,
endsWithSourceProgress: () => false,
getHeartbeatToolResponse: () => undefined,
getPendingToolMediaReply: () => null,
getToolAutoDeliveryMediaUrls: () => [] as string[],
hasToolMediaBlockReply: () => false,

View file

@ -387,35 +387,6 @@ describe("prepareEmbeddedAttemptStream", () => {
},
);
it("retains exact heartbeat preemption on the embedded queue handle", () => {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "session-output-schema",
turnKind: "heartbeat",
resetTriggered: false,
});
try {
const prepared = prepareCatalogExecutor({ replyOperation: operation });
expect(prepared.queueHandle.preemptByVisibleTurn?.()).toBe(true);
expect(operation.result).toEqual({
kind: "aborted",
code: "aborted_for_supersession",
});
expect(mocks.setActiveRun).toHaveBeenCalledWith(
"session-output-schema",
expect.objectContaining({ preemptByVisibleTurn: expect.any(Function) }),
"agent:main:main",
undefined,
"main",
operation,
expect.any(String),
);
} finally {
operation.complete();
}
});
it.each([false, true])(
"keeps finalization closed after revision or unsubscribe (unsubscribe: %s)",
async (unsubscribeDuringHook) => {

View file

@ -532,8 +532,6 @@ function prepareStream(
cancelPendingUserInputAsync: (resolvedBy, preparation, kind) =>
cancelPendingUserInput(resolvedBy, preparation.assertCurrent, kind, preparation),
} satisfies NonNullable<EmbeddedAgentQueueHandle["messageInjectionV2"]>;
const heartbeatReplyOperation =
attempt.replyOperation?.turnKind === "heartbeat" ? attempt.replyOperation : undefined;
const canApplyPermissionMode = () =>
admission.accepting && !input.runAbortController.signal.aborted && isCurrentRegistration();
const applyPermissionMode = input.applyPermissionMode;
@ -567,9 +565,6 @@ function prepareStream(
: undefined,
claimPendingUserInputAnswer,
cancelPendingUserInput,
preemptByVisibleTurn: heartbeatReplyOperation
? () => heartbeatReplyOperation.supersede()
: undefined,
queueMessage,
messageInjection,
messageInjectionV2: messageInjection,

View file

@ -67,7 +67,6 @@ type TerminalAttemptState = Pick<
| "clientToolCalls"
| "yieldDetected"
| "didSendDeterministicApprovalPrompt"
| "heartbeatToolResponse"
| "lastToolError"
| "toolMediaUrls"
| "toolAudioAsVoice"
@ -96,7 +95,6 @@ export function hasAttemptTerminalState(attempt: TerminalAttemptState): boolean
attempt.clientToolCalls ||
attempt.yieldDetected ||
attempt.didSendDeterministicApprovalPrompt ||
attempt.heartbeatToolResponse ||
attempt.toolMediaUrls?.some((url) => url.trim().length > 0) ||
attempt.toolAudioAsVoice ||
attempt.toolTrustedLocalMedia ||

View file

@ -392,8 +392,6 @@ export async function prepareEmbeddedAttemptToolBase(params: {
inputProvenance: attempt.inputProvenance,
disableMessageTool: attempt.disableMessageTool,
forceMessageTool: attempt.forceMessageTool,
enableHeartbeatTool: attempt.enableHeartbeatTool,
forceHeartbeatTool: attempt.forceHeartbeatTool,
inheritedToolAllowlistRef: inheritedToolAllowlist,
additionalInheritedToolNamesRef,
cronCreatorToolAllowlistRef: cronCreatorToolAllowlist,

View file

@ -57,7 +57,6 @@ export function buildEmbeddedAttemptToolRunContext(
thinkLevel?: ThinkLevel;
thinkingExplicit?: boolean;
trigger?: EmbeddedRunTrigger;
continuesConversation?: boolean;
jobId?: string;
memoryFlushWritePath?: string;
memoryFlushTools?: RunEmbeddedAgentParams["memoryFlushTools"];
@ -128,7 +127,6 @@ export function buildEmbeddedAttemptToolRunContext(
? { provider: params.model.provider, model: params.model.id }
: undefined,
trigger: params.trigger,
continuesConversation: params.continuesConversation,
jobId: params.jobId,
memoryFlushWritePath: params.memoryFlushWritePath,
memoryFlushTools: params.memoryFlushTools,

View file

@ -31,7 +31,6 @@ export type IncompleteTurnAttempt = Pick<
| "currentAttemptCompletedAssistant"
| "yieldDetected"
| "didSendDeterministicApprovalPrompt"
| "heartbeatToolResponse"
| "toolMediaUrls"
| "toolAudioAsVoice"
| "toolTrustedLocalMedia"

View file

@ -71,7 +71,6 @@ export function shouldRetrySilentErrorAssistantTurn(params: {
| "clientToolCalls"
| "yieldDetected"
| "didSendDeterministicApprovalPrompt"
| "heartbeatToolResponse"
| "lastToolError"
| "toolMediaUrls"
| "toolAudioAsVoice"

View file

@ -76,7 +76,7 @@ export type RunEmbeddedAgentParams = Pick<
sandboxSessionKey?: string;
/** Explicit sandbox and tool-policy owner when the policy session key is unscoped. */
sandboxAgentId?: string;
/** What initiated this agent run: "user", "heartbeat", "cron", "memory", "overflow", or "manual". */
/** The user, event, automation, or maintenance owner that initiated this run. */
trigger?: EmbeddedRunTrigger;
/** Store-private runtime authority forwarded only by the cron execution owner. */
scheduledRuntimeAuthority?: CronRuntimeAuthority;
@ -127,12 +127,6 @@ export type RunEmbeddedAgentParams = Pick<
promptMode?: PromptMode;
/** Keep the message tool available even when a narrow profile would omit it. */
forceMessageTool?: boolean;
/** Include the heartbeat response tool for structured heartbeat outcomes. */
enableHeartbeatTool?: boolean;
/** Keep the heartbeat response tool available even when a narrow profile would omit it. */
forceHeartbeatTool?: boolean;
/** Heartbeat-transported turn that continues a conversation (its own command completion). */
continuesConversation?: boolean;
/** Allow runtime plugins for this run to late-bind the gateway subagent. */
allowGatewaySubagentBinding?: boolean;
/** @deprecated Use sessionTarget plus sessionId/sessionKey/agentId for runtime identity. */
@ -307,9 +301,6 @@ export type EmbeddedForegroundPromptContext = Pick<
| "contextWindow"
| "promptMode"
| "forceMessageTool"
| "enableHeartbeatTool"
| "forceHeartbeatTool"
| "continuesConversation"
| "allowGatewaySubagentBinding"
| "extraSystemPrompt"
| "gitCoauthorPrompt"

View file

@ -68,7 +68,6 @@ describe("buildEmbeddedRunPayloads tool-error silence", () => {
it.each([
{ name: "a scheduled run", mutatingAction: false, isCronTrigger: true },
{ name: "a heartbeat", mutatingAction: false, isHeartbeatTrigger: true },
{ name: "an aborted run", mutatingAction: false, runAborted: true },
])(
"keeps failure reporting for $name despite NO_REPLY",

View file

@ -1,9 +1,6 @@
import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import { resolveHeartbeatReplyPayload } from "../../../auto-reply/heartbeat-reply-payload.js";
import { selectHeartbeatToolResponse } from "../../../auto-reply/heartbeat-tool-response.js";
import { getReplyPayloadMetadata } from "../../../auto-reply/reply-payload.js";
import { classifyHeartbeatAgentOutcome } from "../../../infra/heartbeat-delivery-normalization.js";
import type { InteractiveReply, MessagePresentation } from "../../../interactive/payload.js";
import { observeReplyDelivery } from "../../reply-completion.js";
import { makeAgentAssistantMessage } from "../../test-helpers/agent-message-fixtures.js";
@ -13,7 +10,6 @@ import {
expectSinglePayloadText,
expectSingleToolErrorPayload,
} from "./payloads.test-helpers.js";
import { mergeAttemptToolMediaPayloads } from "./tool-media-payloads.js";
function signedText(text: string, id: string, phase?: "final_answer", type = "text") {
return { type, text, textSignature: JSON.stringify({ v: 1, id, ...(phase ? { phase } : {}) }) };
@ -284,7 +280,7 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
expect(payloads).toEqual([{ text: "Gateway restarting…" }]);
});
it("keeps heartbeat exec commands and paths private without full verbosity", () => {
it("keeps exec commands and paths private without full verbosity", () => {
const payloads = buildPayloads({
lastToolError: {
toolName: "exec",
@ -292,7 +288,6 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
error:
"tail: cannot open '/home/user/.openclaw/workspace/memory/2026-06-04.md' for reading: No such file or directory",
},
isHeartbeatTrigger: true,
verboseLevel: "off",
});
@ -302,9 +297,8 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
});
});
it("adds a tool warning when a heartbeat failure leaves only reasoning", () => {
it("adds a tool warning when a tool failure leaves only reasoning", () => {
const payloads = buildPayloads({
isHeartbeatTrigger: true,
lastAssistant: {
role: "assistant",
stopReason: "stop",
@ -321,35 +315,10 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
expect(payloads).toHaveLength(2);
expect(payloads[0]).toMatchObject({ text: "Private reasoning only.", isReasoning: true });
expect(resolveHeartbeatReplyPayload(payloads)).toMatchObject({
expect(payloads[1]).toMatchObject({
text: "⚠️ Message failed",
isError: true,
});
for (const payload of payloads) {
expect(getReplyPayloadMetadata(payload)?.heartbeatTerminalToolFailure).toEqual({
toolName: "message",
});
}
});
it("does not infer a terminal mutation from a mixed-action tool name", () => {
const payloads = buildPayloads({
heartbeatToolResponse: {
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
},
isHeartbeatTrigger: true,
lastToolError: {
toolName: "message",
error: "message search failed",
},
});
expectSinglePayloadText(payloads, "HEARTBEAT_OK");
expect(
getReplyPayloadMetadata(payloads[0] as object)?.heartbeatTerminalToolFailure,
).toBeUndefined();
});
it.each([
@ -418,7 +387,6 @@ describe("cron completion after a delivered report", () => {
name: "a send without visible content",
messagingToolSentTargets: [{ ...deliveredReport, text: "", visible: false }],
},
{ name: "a heartbeat", isHeartbeatTrigger: true },
])("still reports failure for $name", ({ name: _name, ...overrides }) => {
expect(buildPayloads({ ...completedRun, ...overrides })).toEqual([
expect.objectContaining({ isError: true }),
@ -460,44 +428,6 @@ describe("buildEmbeddedRunPayloads delivery recovery", () => {
});
});
describe("quiet heartbeat failures", () => {
it.each(["exec"])("does not notify after a failed %s and generated media", (toolName) => {
const payloads = buildPayloads({
assistantTexts: ["Everything is fine."],
heartbeatToolResponse: {
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
},
isHeartbeatTrigger: true,
lastToolError: { toolName, error: "operation failed", mutatingAction: true },
});
const merged = mergeAttemptToolMediaPayloads({
payloads,
toolMediaUrls: ["/tmp/heartbeat.png"],
hostOwnedToolMediaUrls: ["/tmp/heartbeat.png"],
toolAutoDeliveryMediaUrls: ["/tmp/heartbeat.opus"],
toolAudioAsVoice: true,
sourceReplyDeliveryMode: "message_tool_only",
});
expect(
classifyHeartbeatAgentOutcome({
agentRun: {
agentRunFailed: false,
heartbeatToolResponse: selectHeartbeatToolResponse(merged)?.response,
heartbeatTerminalToolFailure: { toolName },
replyPayload: resolveHeartbeatReplyPayload(merged),
},
useHeartbeatFailureCopy: true,
hasRelayableExecCompletion: false,
suppressUnmarkedSourceReplies: false,
responsePrefix: undefined,
ackMaxChars: 300,
}),
).toMatchObject({ kind: "failure", reason: "agent-tool-failure", shouldSkipMain: true });
});
});
describe("buildEmbeddedRunPayloads process-error warnings", () => {
it("surfaces safe terminal diagnostics when verbose mode is off", () => {
const dummyTelegramToken = `123456:${"A".repeat(28)}WXYZ`;

View file

@ -1,9 +1,5 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-options.types.js";
import {
createHeartbeatToolResponsePayload,
type HeartbeatToolResponse,
} from "../../../auto-reply/heartbeat-tool-response.js";
import { buildProviderLoginRecovery } from "../../../auto-reply/provider-login-recovery.js";
import {
addReplyPayloadMediaFailures,
@ -16,7 +12,6 @@ import {
} from "../../../auto-reply/reply-payload.js";
import { parseReplyDirectives } from "../../../auto-reply/reply/reply-directives.js";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import { HEARTBEAT_TOKEN } from "../../../auto-reply/tokens.js";
import { applyAssistantDeliveryDirectives } from "../../../config/sessions/transcript-assistant-delivery.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { readAgentAssistantSource } from "../../../infra/agent-events.js";
@ -127,7 +122,6 @@ export function buildEmbeddedRunPayloads(params: {
config?: OpenClawConfig;
sandboxRuntime?: EmbeddedRunAttemptResult["sandboxRuntime"];
isCronTrigger?: boolean;
isHeartbeatTrigger?: boolean;
sessionKey: string;
provider?: string;
providerOwner?: PreparedProviderFailoverOwner;
@ -151,20 +145,8 @@ export function buildEmbeddedRunPayloads(params: {
deferAssistantTimeoutError?: boolean;
rateLimitRetriesExhausted?: number;
didSendDeterministicApprovalPrompt?: boolean;
heartbeatToolResponse?: HeartbeatToolResponse;
}): ReplyPayload[] {
const sourceReplyPending = params.sourceReplyDeliveryState === "pending";
const heartbeatToolResponse = sourceReplyPending ? undefined : params.heartbeatToolResponse;
const heartbeatTerminalToolFailure =
!sourceReplyPending &&
params.isHeartbeatTrigger === true &&
params.lastToolError &&
params.lastToolError.mutatingAction === true
? { toolName: params.lastToolError.toolName }
: undefined;
if (heartbeatToolResponse && !heartbeatTerminalToolFailure) {
return [createHeartbeatToolResponsePayload(heartbeatToolResponse)];
}
// Internal source replies always need transcript/UI mirrors. A committed
// tool-authored batch replaces the model answer in either delivery mode.
const sourceReplyStateFor = (payloads: MessagingToolSourceReplyPayload[]) =>
@ -179,13 +161,6 @@ export function buildEmbeddedRunPayloads(params: {
);
const replyItems = ordinarySourceReplies.replyItems;
const pendingAuthoredReplies = new Set(sourceReplies.filter((payload) => payload.toolAuthored));
if (heartbeatToolResponse) {
const heartbeatPayload = createHeartbeatToolResponsePayload(heartbeatToolResponse);
replyItems.push({
text: heartbeatPayload.text ?? "",
...(heartbeatPayload.channelData ? { channelData: heartbeatPayload.channelData } : {}),
});
}
const useMarkdown = params.toolResultFormat === "markdown";
let suppressFailureArtifacts = false;
let hasUserFacingReply = false;
@ -218,7 +193,6 @@ export function buildEmbeddedRunPayloads(params: {
? completedSourceReplyViaMessageTool
: ordinarySourceReplies.deliveredSourceReplyViaMessageTool;
const suppressAssistantArtifacts =
heartbeatToolResponse !== undefined ||
params.didSendDeterministicApprovalPrompt === true ||
(params.sourceReplyDeliveryMode === "message_tool_only" &&
ordinarySourceReplies.hasSourceReplyPayload) ||
@ -230,8 +204,7 @@ export function buildEmbeddedRunPayloads(params: {
// Silence belongs to this input's answer. An earlier steered input must not
// hide a later input that actually failed without producing an answer.
hasIntentionalSilentFinal = false;
hasUserFacingReply =
completedSourceReplyViaMessageTool || heartbeatToolResponse?.notify === true;
hasUserFacingReply = completedSourceReplyViaMessageTool;
const authoredReplies = [...pendingAuthoredReplies].filter((payload) =>
isToolAuthoredSourceReplyForAssistant(payload, currentAssistant ?? lastAssistant),
);
@ -501,7 +474,6 @@ export function buildEmbeddedRunPayloads(params: {
(!params.isCronTrigger ||
(hasVisibleCommittedMessagingToolDeliveryEvidence(params) &&
hasCompletedMessagingToolDeliveryEvidence(params))) &&
!params.isHeartbeatTrigger &&
!params.runAborted;
if (params.lastToolError && !respectIntentionalSilence && !sourceReplyPending) {
// A restart intentionally aborts the active tool while the Gateway takes over.
@ -537,9 +509,6 @@ export function buildEmbeddedRunPayloads(params: {
}
}
}
if (heartbeatTerminalToolFailure && !replyItems.some((item) => item.isReasoning !== true)) {
replyItems.push({ text: HEARTBEAT_TOKEN });
}
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
return replyItems
.map((item) => {
@ -556,11 +525,6 @@ export function buildEmbeddedRunPayloads(params: {
) {
markReplyPayloadForSourceSuppressionDelivery(payload);
}
if (heartbeatTerminalToolFailure) {
setReplyPayloadMetadata(payload, {
heartbeatTerminalToolFailure,
});
}
if (
!item.isError &&
!item.isReasoning &&

View file

@ -623,9 +623,6 @@ export async function prepareAndDispatchEmbeddedRunAttempt(
disableToolSearch: params.disableToolSearch,
sessionReadScopeKey: params.sessionReadScopeKey,
forceMessageTool: params.forceMessageTool,
enableHeartbeatTool: params.enableHeartbeatTool,
forceHeartbeatTool: params.forceHeartbeatTool,
continuesConversation: params.continuesConversation,
requireExplicitMessageTarget: params.requireExplicitMessageTarget,
internalEvents: params.internalEvents,
runtimeContextFragments: params.runtimeContextFragments,

View file

@ -26,11 +26,6 @@ function deliveryAttempt(
}
type Observation = "pending" | "observation-failed" | "missing" | "delivered";
const heartbeatResponse = {
outcome: "done" as const,
notify: true,
summary: "Current heartbeat report.",
};
const committedReply = { text: "Earlier committed reply.", sourceReplyFinal: true };
const committedPayload = expect.objectContaining({ text: "Earlier committed reply." });
const textAssistant = (text: string) =>
@ -48,7 +43,7 @@ function observedOutputs<T extends string>(outputs: readonly T[]) {
function deliveryInput(
attempt: EmbeddedRunAttemptWithReceiptEvidence,
observation: Observation,
trigger: "user" | "cron" | "heartbeat" = "cron",
trigger: "user" | "cron" = "cron",
mode: "automatic" | "message_tool_only" = "automatic",
) {
const input = createSettledFinalizationTestInput(
@ -97,33 +92,6 @@ async function resolveCompletion(
}
describe("terminal preparation delivery custody", () => {
it.each(observedOutputs(["response", "failed-response", "failure-only"] as const))(
"retains observed $observation custody for heartbeat $output",
async ({ output, observation }) => {
const attempt = deliveryAttempt(buildEmbeddedRunnerAssistant({ content: [] }));
if (output !== "failure-only") {
attempt.heartbeatToolResponse = { ...heartbeatResponse };
attempt.messagingToolSourceReplyPayloads = [{ ...committedReply }];
attempt.answerSegments = precedingAnswer();
}
if (output !== "response") {
attempt.lastToolError = { toolName: "write", error: "write failed", mutatingAction: true };
}
const input = deliveryInput(attempt, observation, "heartbeat");
const settled = await prepareTerminalWithSettledTurnFinalization(input);
const result = await resolveCompletion(input, settled);
const payloads = result.payloads ?? [];
if (observation === "missing") {
const expected =
output === "failure-only" ? { isError: true } : { text: "Current heartbeat report." };
expect(payloads).toContainEqual(expect.objectContaining(expected));
} else {
expect(payloads).toEqual(output === "failure-only" ? [] : [committedPayload]);
expect(result.sourceReplyDeliveryState).toBe("pending");
}
},
);
it.each(observedOutputs(["text", "error", "media", "timeout"] as const))(
"carries $observation custody through $output preparation and terminal resolution",
async ({ output, observation }) => {
@ -235,24 +203,20 @@ describe("terminal preparation delivery custody", () => {
});
});
it.each([false, true])(
"preserves a preceding input's answer while current delivery remains pending (heartbeat=%s)",
async (heartbeat) => {
const earlier = textAssistant("Earlier answer.");
const current = textAssistant("Current answer.");
const attempt = deliveryAttempt(current, {
assistantTexts: ["Earlier answer.", "Current answer."],
...(heartbeat ? { heartbeatToolResponse: heartbeatResponse } : {}),
});
attempt.answerSegments = precedingAnswer(earlier, "missing", 1);
const input = deliveryInput(attempt, "pending", "cron");
const settled = await prepareTerminalWithSettledTurnFinalization(input);
expect(settled.prepared.payloadsWithToolMedia).toEqual([
expect.objectContaining({ text: "Earlier answer." }),
]);
expect(getReplyPayloadMetadata(settled.prepared.payloadsWithToolMedia![0]!)).toMatchObject({
precedingInputAnswer: true,
});
},
);
it("preserves a preceding input's answer while current delivery remains pending", async () => {
const earlier = textAssistant("Earlier answer.");
const current = textAssistant("Current answer.");
const attempt = deliveryAttempt(current, {
assistantTexts: ["Earlier answer.", "Current answer."],
});
attempt.answerSegments = precedingAnswer(earlier, "missing", 1);
const input = deliveryInput(attempt, "pending", "cron");
const settled = await prepareTerminalWithSettledTurnFinalization(input);
expect(settled.prepared.payloadsWithToolMedia).toEqual([
expect.objectContaining({ text: "Earlier answer." }),
]);
expect(getReplyPayloadMetadata(settled.prepared.payloadsWithToolMedia![0]!)).toMatchObject({
precedingInputAnswer: true,
});
});
});

View file

@ -22,7 +22,6 @@ export function readSubscriptionCompletionEvidence(subscription: EmbeddedAttempt
messagingToolSentMediaUrls: subscription.getMessagingToolSentMediaUrls(),
messagingToolSentTargets: subscription.getMessagingToolSentTargets(),
messagingToolSourceReplyPayloads: subscription.getMessagingToolSourceReplyPayloads(),
heartbeatToolResponse: subscription.getHeartbeatToolResponse(),
sourceReplyDelivered: subscription.getSourceReplyDelivered(),
sourceReplyDeliveryState: subscription.getSourceReplyDeliveryState(),
toolMediaUrls: media?.mediaUrls,

View file

@ -61,11 +61,10 @@ type PreparationOverrides = Partial<Omit<PreparationInput, "attempt" | "runParam
runParams?: Partial<PreparationInput["runParams"]>;
admittedRunContext?: PreparationInput["runParams"]["admittedRunContext"];
sourceReplyDeliveryMode?: "message_tool_only";
heartbeat?: { continuesConversation: boolean };
};
function preparationInput(input: PreparationOverrides): PreparationInput {
const { admittedRunContext, sourceReplyDeliveryMode, heartbeat, runParams, ...overrides } = input;
const { admittedRunContext, sourceReplyDeliveryMode, runParams, ...overrides } = input;
return {
runParams: {
admittedRunContext: admittedRunContext ?? createTestAdmittedRunContext("run-focused"),
@ -73,8 +72,7 @@ function preparationInput(input: PreparationOverrides): PreparationInput {
runId: "run-focused",
workspaceDir: "/tmp/openclaw-test",
prompt: "hi",
trigger: heartbeat ? "heartbeat" : "user",
...(heartbeat?.continuesConversation ? { continuesConversation: true } : {}),
trigger: "user",
timeoutMs: 60_000,
...(sourceReplyDeliveryMode ? { sourceReplyDeliveryMode } : {}),
...runParams,

View file

@ -46,35 +46,16 @@ describe("prepareEmbeddedRunTerminal", () => {
payloadMocks.buildEmbeddedRunPayloads.mockReset().mockReturnValue([]);
});
it.each([
{
name: "an acknowledgement-only user reply",
payload: { text: "HEARTBEAT_OK" },
heartbeat: undefined,
visible: false,
},
{
name: "a real heartbeat acknowledgement",
payload: { text: "HEARTBEAT_OK" },
heartbeat: { continuesConversation: false },
visible: true,
},
{
name: "a heartbeat-transported conversation continuation",
payload: { text: "HEARTBEAT_OK" },
heartbeat: { continuesConversation: true },
visible: false,
},
])("keeps $name aligned with reply presentation", async ({ payload, heartbeat, visible }) => {
it("keeps an acknowledgement-only user reply aligned with presentation", async () => {
const payload = { text: "HEARTBEAT_OK" };
payloadMocks.buildEmbeddedRunPayloads.mockReturnValueOnce([payload]);
const prepared = await prepareAttempt({
attempt: attemptResult(),
heartbeat,
});
expect(prepared.payloadsWithToolMedia).toEqual([payload]);
expect(prepared.payloadsForVisibleCompletion).toEqual(visible ? [payload] : []);
expect(prepared.payloadsForVisibleCompletion).toEqual([]);
});
it.each([
@ -720,38 +701,4 @@ describe("prepareEmbeddedRunTerminal", () => {
expect.objectContaining({ runAborted: true, runStopReason: "restart" }),
);
});
it.each([
{ continuesConversation: false, warns: true },
{ continuesConversation: true, warns: false },
])(
"keeps a failed command's NO_REPLY silent only when the heartbeat continues a conversation ($continuesConversation)",
async ({ continuesConversation, warns }) => {
const actual = await vi.importActual<{
buildEmbeddedRunPayloads: typeof buildEmbeddedRunPayloads;
}>("./payloads.js");
payloadMocks.buildEmbeddedRunPayloads.mockImplementation(actual.buildEmbeddedRunPayloads);
const silent: AssistantMessage = {
...assistantMessage("stop"),
content: [{ type: "text", text: "NO_REPLY" }],
};
const prepared = await prepareAttempt({
attempt: attemptResult({
assistantTexts: ["NO_REPLY"],
messagesSnapshot: [silent],
lastAssistant: silent,
currentAttemptAssistant: silent,
currentAttemptCompletedAssistant: silent,
lastToolError: { toolName: "exec", error: "Command exited with code 1" },
}),
heartbeat: { continuesConversation },
terminalState: {
outcome: { reason: "completed", status: "ok", stopReason: "stop" },
signalOwnedInterruption: false,
},
});
expect(prepared.payloads.some((payload) => payload.isError === true)).toBe(warns);
},
);
});

View file

@ -250,8 +250,6 @@ export function prepareEmbeddedRunTerminal(input: {
lastToolError: cleanYield ? undefined : attempt.lastToolError,
config: runParams.config,
isCronTrigger: runParams.trigger === "cron",
// A conversation's continuation keeps conversational silence and failure reporting.
isHeartbeatTrigger: runParams.trigger === "heartbeat" && !runParams.continuesConversation,
sessionKey: runParams.sessionKey ?? runParams.sessionId,
provider: input.activeErrorContext.provider,
providerOwner: input.providerOwner,
@ -362,7 +360,6 @@ export function prepareEmbeddedRunTerminal(input: {
(attempt.toolMetas?.length ?? 0) === 0;
const payloadsForVisibleCompletion = filterAgentRunTerminalPayloadsForPresentation({
payloads: recoveredFinalAssistantPayloadsAfterPromptTimeout ?? payloadsWithToolMedia,
isHeartbeatTrigger: runParams.trigger === "heartbeat" && !runParams.continuesConversation,
});
const attemptToolSummary = buildTraceToolSummary(attempt);
// Cron execution denials must not look like successful silent completions.

View file

@ -1,6 +1,5 @@
import type { AgentRunTimeoutPhase } from "@openclaw/normalization-core/agent-run-terminal-outcome";
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { HeartbeatToolResponse } from "../../../auto-reply/heartbeat-tool-response.js";
import type { ThinkLevel } from "../../../auto-reply/thinking.js";
import type {
SessionContextBudgetStatus,
@ -435,7 +434,6 @@ export type EmbeddedRunAttemptResult = {
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls?: string[];
/**
* Native artifacts produced and owned by the harness, never model-selected

View file

@ -1,4 +1,3 @@
import type { HeartbeatToolResponse } from "../../auto-reply/heartbeat-tool-response.js";
import type { ContextEngineSessionTarget } from "../../context-engine/types.js";
import type { DiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js";
import type { AcceptedSessionSpawn } from "../accepted-session-spawn.js";
@ -60,8 +59,6 @@ export type EmbeddedAgentRunResult = {
asyncWorkStarted?: true;
/** Completed core yield settlement, not a requester-visible final reply. */
requesterContinuationSettled?: true;
// Structured heartbeat outcome recorded by the heartbeat response tool.
heartbeatToolResponse?: HeartbeatToolResponse;
successfulCronAdds?: number;
};

View file

@ -8,7 +8,6 @@ import { Type } from "typebox";
import { expect, it, onTestFinished, vi } from "vitest";
import { createResponsesAssistantOutput } from "../../packages/ai/src/providers/openai-responses-shared.js";
import { processResponsesStream } from "../../packages/ai/src/transports/openai-responses-stream-internal.js";
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
import { buildReplyPayloads } from "../auto-reply/reply/agent-runner-payloads.js";
import { createBlockReplyPipeline } from "../auto-reply/reply/block-reply-pipeline.js";
import { createBlockReplyDeliveryHandler } from "../auto-reply/reply/reply-delivery.js";
@ -129,7 +128,7 @@ type Scenario = {
transcript: readonly string[];
delivered: readonly string[];
reply?: { disposition: "visible"; text: string } | { disposition: "empty" | "silent" };
heartbeat?: boolean;
trigger?: "cron";
persistedFirst?: boolean;
quiet?: boolean;
reasoning?: boolean;
@ -145,9 +144,9 @@ const scenario = (name: string, overrides: Partial<Omit<Scenario, "name">> = {})
});
it.each([
scenario("a later NO_REPLY keeps the completed answer"),
scenario("a heartbeat turn without block streaming keeps the completed answer", {
scenario("an automation turn without block streaming keeps the completed answer", {
delivery: "off",
heartbeat: true,
trigger: "cron",
}),
scenario("a live answer followed by commentary is not resent after NO_REPLY", {
delivery: "live",
@ -225,7 +224,7 @@ it.each([
reasoning: true,
}),
])("$name", async (row) => {
const { delivery, requests, transcript, delivered, heartbeat = false, quiet = false } = row;
const { delivery, requests, transcript, delivered, trigger = "user", quiet = false } = row;
const sent: string[] = [];
const show = (payload: { text?: string; mediaUrl?: string }) =>
payload.text ?? payload.mediaUrl ?? "";
@ -244,7 +243,7 @@ it.each([
typingSignals: createTypingSignaler({
typing: createTypingController({}),
mode: "never",
isHeartbeat: heartbeat,
isHeartbeat: false,
}),
blockStreamingEnabled,
reasoningPayloadsEnabled: row.reasoning === true,
@ -358,10 +357,10 @@ it.each([
workspaceDir: "/tmp/openclaw-test",
prompt: "Where do I store my bag?",
timeoutMs: 60_000,
trigger: heartbeat ? ("heartbeat" as const) : ("user" as const),
trigger,
...(quiet ? { sourceReplyDeliveryMode: "message_tool_only" as const } : {}),
...(row.reasoning ? { reasoningLevel: "on" as const } : {}),
};
} satisfies Parameters<typeof prepareEmbeddedRunTerminal>[0]["runParams"];
const terminalState = {
outcome: { reason: "completed", status: "ok", stopReason: "stop" } as const,
signalOwnedInterruption: false,
@ -389,15 +388,12 @@ it.each([
visibleText,
);
}
if (heartbeat) {
expect(resolveHeartbeatReplyPayload(prepared.payloads)?.text).toBe("Use counter B.");
}
const { replyPayloads } = await buildReplyPayloads({
payloads: prepared.payloads,
isHeartbeat: heartbeat,
blockStreamingEnabled,
blockReplyPipeline: pipeline,
replyToMode: "off",
isHeartbeat: false,
});
expect([...sent, ...replyPayloads.map(show)]).toEqual(delivered);
if (row.reply) {

View file

@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js";
import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
import type { AssistantMessage } from "../llm/types.js";
import { buildEmbeddedRunPayloads } from "./embedded-agent-runner/run/payloads.js";
@ -270,41 +269,23 @@ it("delivers queued media after a rejected reasoning reply", async () => {
expectMediaState(h, null, 1);
});
it.each(["progress", "heartbeat"] as const)("contains rejected %s callbacks", async (kind) => {
it("contains rejected progress callbacks", async () => {
const rejected = vi.fn().mockRejectedValue(new Error("callback failed"));
const h = setup(
kind === "progress"
? {
onAgentEvent: rejected,
onPartialReply: rejected,
onAssistantMessageStart: rejected,
onReasoningStream: rejected,
onReasoningEnd: rejected,
reasoningMode: "stream",
}
: {
onHeartbeatToolResponse: rejected,
verboseLevel: "full",
},
);
if (kind === "progress") {
h.message(answer("Hello"), false);
emitAssistantTextDelta({ emit: h.emit, delta: "Hello" });
for (const assistantMessageEvent of [
{ type: "thinking_delta", delta: "Because" },
{ type: "thinking_end" },
]) {
h.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent });
}
} else {
h.tool(HEARTBEAT_RESPONSE_TOOL_NAME, {
details: {
status: "accepted",
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
},
});
const h = setup({
onAgentEvent: rejected,
onPartialReply: rejected,
onAssistantMessageStart: rejected,
onReasoningStream: rejected,
onReasoningEnd: rejected,
reasoningMode: "stream",
});
h.message(answer("Hello"), false);
emitAssistantTextDelta({ emit: h.emit, delta: "Hello" });
for (const assistantMessageEvent of [
{ type: "thinking_delta", delta: "Because" },
{ type: "thinking_end" },
]) {
h.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent });
}
await h.drain();
await new Promise<void>((resolve) => {

View file

@ -132,7 +132,6 @@ export function handleAgentEnd(
const hasTerminalOutput = hasAttemptTerminalState({
yieldDetected: ctx.state.yielded,
didSendDeterministicApprovalPrompt: ctx.state.deterministicApprovalPromptSent,
heartbeatToolResponse: ctx.state.heartbeatToolResponse,
lastToolError: ctx.state.lastToolError,
toolMediaUrls: [...ctx.state.pendingToolMediaUrls, ...deferredMediaUrls],
toolAudioAsVoice:

View file

@ -1,9 +1,5 @@
import { asOptionalObjectRecord, asRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
normalizeHeartbeatToolResponse,
} from "../auto-reply/heartbeat-tool-response.js";
import { projectAgentToolActivity } from "../infra/agent-activity-events.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
@ -39,7 +35,6 @@ import {
isMessagingToolTargetEvidenceAction,
} from "./embedded-agent-messaging.js";
import { mergeEmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js";
import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js";
import {
applyCurrentMessageProvider,
applyToolSendReceiptForExtraction,
@ -396,21 +391,6 @@ export async function prepareToolExecutionEnd(
) {
ctx.state.successfulCronAdds += 1;
}
if (!isToolError && toolName === HEARTBEAT_RESPONSE_TOOL_NAME) {
const response = normalizeHeartbeatToolResponse(resultRecord?.details);
if (response) {
const isFirstHeartbeatResponse = ctx.state.heartbeatToolResponse === undefined;
ctx.state.heartbeatToolResponse = response;
if (isFirstHeartbeatResponse) {
runBestEffortCallback({
label: "heartbeat tool response",
log: ctx.log,
callback: () => ctx.params.onHeartbeatToolResponse?.(response),
});
}
}
}
const planUpdate =
!isToolError && toolName === "progress_card"
? projectProgressCardChannelUpdate(startArgs)

View file

@ -1,7 +1,6 @@
import type { AgentRunTimeoutPhase } from "@openclaw/normalization-core/agent-run-terminal-outcome";
import type { InlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
import type { FenceScanState } from "../../packages/markdown-core/src/fences.js";
import type { HeartbeatToolResponse } from "../auto-reply/heartbeat-tool-response.js";
import type { ReplyMediaAttachment } from "../auto-reply/reply-payload.js";
import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
@ -203,7 +202,6 @@ export class EmbeddedAgentSubscribeState {
currentSourceMessagingToolSentTextsNormalized: string[] = [];
currentSourceMessagingToolHeldPartial?: string = undefined;
messagingToolSentTargets: MessagingToolSend[] = [];
heartbeatToolResponse?: HeartbeatToolResponse = undefined;
messagingToolSentMediaUrls: string[] = [];
messagingToolSourceReplyPayloads: MessagingToolSourceReplyPayload[] = [];
messageToolOnlySourceReplyDelivered = false;

View file

@ -1,5 +1,5 @@
import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
import { assert, expect, it, onTestFinished, vi } from "vitest";
import { expect, it, onTestFinished, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import type { EmbeddedContextAccountingEvent } from "./embedded-agent-runner/run/internal-params.js";
import {
@ -10,7 +10,6 @@ import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js";
import type { SubscribeEmbeddedAgentSessionParams } from "./embedded-agent-subscribe.types.js";
import type { AgentSessionEvent } from "./sessions/index.js";
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
import { resolveHeartbeatResponseTool } from "./tools/heartbeat-response-tool.js";
import { makeZeroUsageSnapshot } from "./usage.js";
function harness(params: Omit<Parameters<typeof createSubscribedSessionHarness>[0], "runId"> = {}) {
@ -169,43 +168,6 @@ it("captures repaired model usage before queued delivery and throwing cleanup",
expect(observed).toEqual(expected);
});
it("preserves an accepted heartbeat response and private scratch through retry", async () => {
const onHeartbeatToolResponse = vi.fn();
const { emit, subscription, startTool } = harness({
sessionPersistence: "detached",
onHeartbeatToolResponse,
});
const tool = resolveHeartbeatResponseTool({ enableHeartbeatTool: true });
assert(tool);
const response = {
outcome: "done" as const,
notify: true,
summary: "The monitored task completed.",
notificationText: "Your report is ready.",
scratch: "Private monitor notes: report completion confirmed.",
};
startTool(tool.name, response, "heartbeat");
const result = await tool.execute("heartbeat", response);
emit({
type: "tool_execution_end",
toolName: tool.name,
toolCallId: "heartbeat",
isError: false,
result,
});
await subscription.waitForPendingEvents();
expect(subscription.getHeartbeatToolResponse()).toEqual(response);
emit(completed());
const reply = assistant(0);
message(emit, reply);
emit({ type: "agent_end", messages: [reply] });
await subscription.waitForPendingEvents();
await subscription.waitForCompactionRetry();
expect(subscription.getHeartbeatToolResponse()).toEqual(response);
expect(onHeartbeatToolResponse).toHaveBeenCalledExactlyOnceWith(response);
expect(subscription.getCompactionCount()).toBe(1);
});
it("clears the assistant and its exact usage when compaction starts a new attempt", () => {
const { emit, attempt } = harness();
const reply = assistant(100, {

View file

@ -478,8 +478,6 @@ export function subscribeEmbeddedAgentSession(input: SubscribeEmbeddedAgentSessi
getSourceReplyDelivered: () => state.sourceReplyDelivered,
getSourceReplyDeliveryState: () => state.sourceReplyDeliveryState,
endsWithSourceProgress: () => state.lastToolTurnOnlySourceProgress === true,
getHeartbeatToolResponse: () =>
state.heartbeatToolResponse ? { ...state.heartbeatToolResponse } : undefined,
getPendingToolMediaReply: () => readPendingToolMediaReply(state),
getToolAutoDeliveryMediaUrls: () => [...state.toolAutoDeliveryMediaUrls],
hasToolMediaBlockReply: () => state.hasToolMediaBlockReply,

View file

@ -1,7 +1,6 @@
/**
* Public parameter types for subscribing to embedded-agent sessions.
*/
import type { HeartbeatToolResponse } from "../auto-reply/heartbeat-tool-response.js";
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import type { AssistantMessage } from "../llm/types.js";
import type { HookRunner } from "../plugins/hooks.js";
@ -75,7 +74,6 @@ export type SubscribeEmbeddedAgentSessionParams = Pick<
toolCallId?: string;
source?: string;
}) => void;
onHeartbeatToolResponse?: (response: HeartbeatToolResponse) => void | Promise<void>;
/** "finishing" defers both success and error terminal ownership to the caller. */
terminalLifecyclePhase?: "end" | "finishing";
/** Read immediately before terminal lifecycle emission. */

View file

@ -106,7 +106,6 @@ export async function prepareHarnessReplyMedia(params: {
messagingToolSentTargets: result.messagingToolSentTargets,
messagingToolSourceReplyPayloads: result.messagingToolSourceReplyPayloads,
didSendDeterministicApprovalPrompt: result.didSendDeterministicApprovalPrompt,
heartbeatToolResponse: result.heartbeatToolResponse,
runAborted: result.terminal.kind === "aborted",
});
const sources = [

View file

@ -21,10 +21,6 @@ import {
rotateAgentRunRegistryLifecycleGeneration,
} from "../../infra/agent-run-registry.js";
import { sha256HexPrefixCore } from "../../infra/crypto-digest.js";
import {
claimHeartbeatOutcomeForRun,
persistHeartbeatOutcome,
} from "../../infra/heartbeat-outcome-store.js";
import { createOpenClawCodingTools } from "../../plugin-sdk/agent-harness.js";
import { createPluginRecord } from "../../plugins/loader-records.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
@ -628,65 +624,6 @@ describe("runAgentHarnessAttempt", () => {
},
);
it("carries silent heartbeat outcome into the plugin host boundary exactly once per retry", async () => {
const harnessId = "codex";
const root = trajectoryTempDirs.make("harness-heartbeat-outcome-");
vi.stubEnv("OPENCLAW_STATE_DIR", root);
const target = {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath: path.join(root, "agent.sqlite"),
};
await replaceSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 });
await persistHeartbeatOutcome({
...target,
runSessionKey: "agent:main:main:heartbeat",
occurredAt: 1,
response: { outcome: "done", notify: false, summary: "ISOLATED_OUTCOME_731" },
});
const runAttempt = vi.fn<AgentHarness["runAttempt"]>(async () => createAttemptResult("native"));
registerHarness({ runAttempt });
const currentInboundContext = {
text: "Current quoted reply",
resumableText: "Current room delta",
promptJoiner: "\n" as const,
fragments: [{ kind: "conversation-data" as const, text: "Current quoted reply" }],
};
const params = {
...createAttemptParams(),
...target,
sessionTarget: target,
trigger: "user" as const,
agentHarnessId: harnessId,
currentInboundContext,
};
for (let retry = 0; retry < 2; retry++) {
await runAgentHarnessAttempt(params);
const received = runAttempt.mock.calls.at(-1)?.[0];
expect(received?.currentInboundContext?.text.match(/ISOLATED_OUTCOME_731/g)).toHaveLength(1);
expect(
received?.currentInboundContext?.resumableText?.match(/ISOLATED_OUTCOME_731/g),
).toHaveLength(1);
expect(received?.currentInboundContext?.promptJoiner).toBe("\n");
const fragments = received?.currentInboundContext?.fragments;
expect(fragments).toEqual([
...currentInboundContext.fragments,
{ kind: "heartbeat-outcome", text: expect.stringContaining("ISOLATED_OUTCOME_731") },
]);
expect(JSON.stringify(fragments)).toContain("ISOLATED_OUTCOME_731");
expect(received?.prompt).toBe("hello");
expect(params.currentInboundContext).toEqual(currentInboundContext);
expect(currentInboundContext.text).toBe("Current quoted reply");
}
expect(JSON.stringify(await loadTranscriptEvents(target))).not.toContain(
"ISOLATED_OUTCOME_731",
);
expect(
await claimHeartbeatOutcomeForRun({ ...target, runId: "later-user-run" }),
).toBeUndefined();
});
it("carries a runtime-event completion result into the plugin harness inbound context", async () => {
const runAttempt = vi.fn<AgentHarness["runAttempt"]>(async () => createAttemptResult("native"));
registerHarness({ runAttempt });
@ -728,35 +665,6 @@ describe("runAgentHarnessAttempt", () => {
});
});
it("does not consume silent heartbeat context for an aborted host attempt", async () => {
const root = trajectoryTempDirs.make("harness-heartbeat-control-");
vi.stubEnv("OPENCLAW_STATE_DIR", root);
const target = {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath: path.join(root, "agent.sqlite"),
};
await replaceSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 });
await persistHeartbeatOutcome({
...target,
runSessionKey: "agent:main:main:heartbeat",
occurredAt: 1,
response: { outcome: "done", notify: false, summary: "Retained outcome" },
});
const params = {
...createAttemptParams(),
...target,
sessionTarget: target,
trigger: "user" as const,
abortSignal: AbortSignal.abort(),
};
await expect(runAgentHarnessAttempt(params)).rejects.toThrow();
expect((await claimHeartbeatOutcomeForRun({ ...target, runId: "next-user" }))?.summary).toBe(
"Retained outcome",
);
});
it.each(["workspace", "explicit cwd"] as const)(
"binds native provenance to staged input before dispatch and preserves it on a suppressed retry (%s)",
async (directorySource) => {

View file

@ -9,7 +9,6 @@ import {
runWithDiagnosticTraceContext,
} from "../../infra/diagnostic-trace-context.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { claimHeartbeatContextForUserRun } from "../../infra/heartbeat-outcome-store.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { hasGlobalHooks } from "../../plugins/hook-runner-global.js";
import {
@ -18,14 +17,12 @@ import {
readRunOperatorAuthority,
resolveAdmittedRunActiveAssertion,
} from "../admitted-run-context.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import {
isHostScopedAgentToolActive,
runWithAgentRingZeroTools,
} from "../agent-tools.ring-zero-context.js";
import { isHeartbeatLifecycleRunKind } from "../bootstrap-mode.js";
import type { EmbeddedRunAttemptInternalParams } from "../embedded-agent-runner/run/internal-params.js";
import { appendCurrentInboundContext } from "../embedded-agent-runner/run/runtime-context-prompt.js";
import type {
EmbeddedRunAttemptParams,
EmbeddedRunAttemptResult,
@ -440,32 +437,7 @@ export async function runAgentHarnessAttempt(
? { ...input, toolsAllow: [] }
: input;
},
(prepared) =>
pluginAttempt.runWithHostScope(async () => {
if (prepared.trigger !== "user" || !prepared.sessionKey) {
return runPreparedAttempt(prepared);
}
const note = await claimHeartbeatContextForUserRun({
...prepared,
agentId: resolveSessionAgentIds(prepared).sessionAgentId,
storePath: prepared.sessionTarget?.storePath,
detached: prepared.sessionPersistence === "detached",
assertCurrent: resolveAdmittedRunActiveAssertion(
internalParams.admittedRunContext,
prepared.abortSignal,
),
});
if (!note) {
return runPreparedAttempt(prepared);
}
return runPreparedAttempt({
...prepared,
currentInboundContext: appendCurrentInboundContext(
prepared.currentInboundContext,
[{ kind: "heartbeat-outcome", text: note }],
),
});
}),
(prepared) => pluginAttempt.runWithHostScope(() => runPreparedAttempt(prepared)),
),
);
}),

View file

@ -123,7 +123,6 @@ export function projectSettledTurnFinalizationAttemptResult(
result.messagingToolSentMediaUrls.length > 0 ||
result.messagingToolSentTargets.length > 0 ||
(result.messagingToolSourceReplyPayloads?.length ?? 0) > 0 ||
result.heartbeatToolResponse ||
(result.toolMediaUrls?.length ?? 0) > 0 ||
(result.hostOwnedToolMediaUrls?.length ?? 0) > 0 ||
result.toolAudioAsVoice ||

View file

@ -1,5 +1,4 @@
import { expect, it } from "vitest";
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../../auto-reply/heartbeat-tool-response.js";
import {
collectMessagingMediaUrlsFromRecord,
collectMessagingMediaUrlsFromToolResult,
@ -93,19 +92,3 @@ it("records messaging tool side effects", () => {
},
]);
});
it("accepts heartbeat response tool outcomes", () => {
const response = {
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
};
const telemetry = recordTelemetry({
toolName: HEARTBEAT_RESPONSE_TOOL_NAME,
result: textToolResult("Accepted.", { status: "accepted", ...response }),
});
expect(telemetry.heartbeatToolResponse).toEqual(response);
});

View file

@ -1,10 +1,5 @@
import { asOptionalRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
import type { AgentToolResult } from "../../../packages/agent-core/src/types.js";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
normalizeHeartbeatToolResponse,
type HeartbeatToolResponse,
} from "../../auto-reply/heartbeat-tool-response.js";
import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
@ -39,12 +34,6 @@ export function recordAgentHarnessToolResultTelemetry(params: {
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1;
}
if (!params.isError && params.toolName === HEARTBEAT_RESPONSE_TOOL_NAME) {
const response = normalizeHeartbeatToolResponse(params.result?.details);
if (response) {
params.telemetry.heartbeatToolResponse = response;
}
}
// Only a live invocation may accept new media; committed effects remain evidence.
if (!params.isError && params.result && !params.signal.aborted) {
const media = recordAgentHarnessToolResultMedia({
@ -253,7 +242,6 @@ export type AgentHarnessToolResultTelemetry = AgentHarnessMessagingDeliveryFacts
)
>;
successfulCronAdds?: number;
heartbeatToolResponse?: HeartbeatToolResponse;
};
function readFirstString(record: Record<string, unknown>, keys: string[]): string | undefined {

View file

@ -62,7 +62,6 @@ import {
createGetGoalTool,
createUpdateGoalTool,
} from "./tools/goal-tools.js";
import { resolveHeartbeatResponseTool } from "./tools/heartbeat-response-tool.js";
import { createImageGenerateTool } from "./tools/image-generate-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { callAgentToolGatewayRequest } from "./tools/in-process-gateway.js";
@ -371,7 +370,6 @@ function* createOpenClawToolsSteps(
workspaceDir,
},
})[0] ?? null);
const heartbeatTool = resolveHeartbeatResponseTool(options);
options?.recordToolPrepStage?.("openclaw-tools:message-tool");
const nodesToolBase = createNodesTool({
...options,
@ -519,7 +517,6 @@ function* createOpenClawToolsSteps(
presenters: widgetPresentation.presenters,
presenterContext: widgetPresentation.context,
}),
heartbeatTool,
createDecisionTool(sessionAgentId, options),
createTtsTool({ ...options, agentId: sessionAgentId }),
options?.githubPublicationAvailable !== undefined ? createGitHubIdentityStatusTool() : null,

View file

@ -103,10 +103,6 @@ export type OpenClawSharedToolsOptions = {
disableMessageTool?: boolean;
swarmCollector?: boolean;
swarmOutputSchema?: Record<string, unknown>;
/** If true, include the heartbeat response tool for structured heartbeat outcomes. */
enableHeartbeatTool?: boolean;
/** Offer the heartbeat tool definition without accepting responses outside heartbeat runs. */
offerHeartbeatTool?: boolean;
/** Host-only observation after a canonical progress-card replacement commits. */
onProgressCardPlanSaved?: (unfinished: boolean) => void;
onYield?: SessionsYieldCallback;

View file

@ -9,10 +9,7 @@ import {
import { tryFastAbortFromMessage } from "../../../auto-reply/reply/abort.js";
import { handleStopCommand } from "../../../auto-reply/reply/commands-session-abort.js";
import { buildCommandTestParams } from "../../../auto-reply/reply/commands.test-harness.js";
import {
recordReplyOperationAgentTurn,
type ReplyOperationRunState,
} from "../../../auto-reply/reply/reply-operation-run-state.js";
import { drainFormattedSystemEvents } from "../../../auto-reply/reply/session-system-events.js";
import { buildTestCtx } from "../../../auto-reply/reply/test-ctx.js";
import { replaceSessionEntry } from "../../../config/sessions/session-accessor.js";
import {
@ -29,12 +26,6 @@ import {
withExecRequestOwners,
withExecRequestTurn,
} from "../../../infra/exec-request-context.js";
import { createHeartbeatDispatch } from "../../../infra/heartbeat-dispatch.js";
import {
prepareHeartbeatRunStage,
resolveHeartbeatWakeStage,
type HeartbeatRunOptions,
} from "../../../infra/heartbeat-runner-execution.js";
import {
consumeSelectedSystemEventEntries,
enqueueSystemEventEntry,
@ -338,37 +329,23 @@ export function registerCompletedRequestCustodySpawnCases(options: RequestCustod
expect(captureExecRequestCancellation(original).owners).toEqual(owners);
const stop = createCustodyStop(bound, original);
if (mode !== "retained-exact") {
// The bound native-parent fixture replaces model admission. Use the real
// heartbeat preparation and acknowledgement owner to retire its event.
const heartbeatOptions: HeartbeatRunOptions = {
// The bound native-parent fixture replaces model admission. Retire the
// selected occurrence through the ordinary prompt-adoption boundary.
const admittedEvents: string[] = [];
const admittedContext = await drainFormattedSystemEvents({
cfg: bound.cfg,
agentId: original.agentId,
sessionKey: original.sessionKey,
source: "exec-event",
intent: "event",
reason: "exec-event",
heartbeat: { every: "1h", target: "none", isolatedSession: false },
};
const wake = await resolveHeartbeatWakeStage(heartbeatOptions);
if (wake.kind !== "ready") {
throw new Error(`Heartbeat acknowledgement preparation skipped: ${wake.reason}`);
}
const prepared = await prepareHeartbeatRunStage(wake);
if (prepared.kind !== "ready") {
throw new Error(`Heartbeat acknowledgement routing skipped: ${prepared.reason}`);
}
expect(prepared.inspectedSystemEventsToConsume.map((selected) => selected.id)).toContain(
event.id,
);
const replyState: ReplyOperationRunState = {};
recordReplyOperationAgentTurn([replyState], undefined, {
kind: "settled",
status: "ok",
result: { acceptedSessionSpawns: [accepted] },
isMainSession: true,
isNewSession: false,
events: [event],
onEventsAdmitted: (events) =>
admittedEvents.push(
...events.map((entry) => expectDefined(entry.id, "admitted occurrence id")),
),
});
const acknowledgement = createHeartbeatDispatch(heartbeatOptions, wake, prepared);
await acknowledgement.prepareReply({ text: "HEARTBEAT_OK" }, replyState);
expect(acknowledgement.result).toMatchObject({ status: "ran" });
expect(admittedEvents).toEqual([event.id]);
expect(admittedContext).toContain(event.text);
expect(
peekSystemEventEntries(original.sessionKey).map((selected) => selected.id),
).not.toContain(event.id);

View file

@ -27,7 +27,6 @@ const coreTools = [
stubActionTool("nodes", ["list", "invoke"]),
stubActionTool("automations", ["schedule", "cancel"]),
stubActionTool("message", ["send", "reply"]),
stubTool("heartbeat_respond"),
stubActionTool("gateway", ["config.get", "config.schema.lookup"]),
stubTool("openclaw"),
stubActionTool("agents_list", ["list", "show"]),
@ -52,11 +51,9 @@ const coreTools = [
];
const createOpenClawToolsMock = vi.fn(
(options?: { enableHeartbeatTool?: boolean; recordToolPrepStage?: (name: string) => void }) => {
(options?: { recordToolPrepStage?: (name: string) => void }) => {
options?.recordToolPrepStage?.("openclaw-tools:test-helper");
return coreTools
.filter((tool) => tool.name !== "heartbeat_respond" || options?.enableHeartbeatTool === true)
.map((tool) => Object.assign({}, tool));
return coreTools.map((tool) => Object.assign({}, tool));
},
);

View file

@ -479,11 +479,9 @@ describe("Tool Search", () => {
it("keeps run-contract tools direct-only so search never hides them", async () => {
const { createStructuredOutputTool } = await import("./tools/structured-output-tool.js");
const { createSessionsYieldTool } = await import("./tools/sessions-yield-tool.js");
const { resolveHeartbeatResponseTool } = await import("./tools/heartbeat-response-tool.js");
const contractTools = [
createStructuredOutputTool({ runId: "run-contract-tools", schema: { type: "object" } }),
createSessionsYieldTool({ sessionId: "session-contract-tools" }),
expectDefined(resolveHeartbeatResponseTool({ enableHeartbeatTool: true }), "heartbeat tool"),
];
const { catalogRef, compacted } = catalogFixture([
@ -495,7 +493,6 @@ describe("Tool Search", () => {
...controlNames,
"structured_output",
"sessions_yield",
"heartbeat_respond",
]);
expect(catalogRef.current?.entries.map((entry) => entry.name)).toEqual(["fake_lookup"]);
});

View file

@ -1,162 +0,0 @@
// Heartbeat response tool tests cover the one-shot heartbeat contract and
// provider-portable schema shape.
import { assert, describe, expect, it } from "vitest";
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../../auto-reply/heartbeat-tool-response.js";
import { resolveHeartbeatResponseTool } from "./heartbeat-response-tool.js";
function readSchemaProperty(schema: unknown, key: string): Record<string, unknown> {
const root = schema as { properties?: Record<string, unknown> };
const property = root.properties?.[key];
if (property === undefined) {
throw new Error(`expected schema property ${key}`);
}
return property as Record<string, unknown>;
}
type HeartbeatResponseDetails = {
status?: string;
outcome?: string;
notify?: boolean;
summary?: string;
notificationText?: string;
priority?: string;
nextCheck?: string;
scratch?: string;
};
describe("resolveHeartbeatResponseTool", () => {
it("uses flat enum schemas for provider portability", () => {
// Some providers reject anyOf literal unions in tool schemas; flat enums are
// the portable contract for heartbeat status fields.
const tool = resolveHeartbeatResponseTool({ enableHeartbeatTool: true });
assert(tool);
const outcome = readSchemaProperty(tool.parameters, "outcome");
const priority = readSchemaProperty(tool.parameters, "priority");
const scratch = readSchemaProperty(tool.parameters, "scratch");
expect(tool.catalogMode).toBe("direct-only");
expect(tool.description).toContain("Scratch is monitor prose only.");
expect(tool.description).not.toMatch(/\b(?:cron|automations)\b/);
expect(JSON.stringify(tool.parameters)).not.toMatch(/\b(?:cron|automations)\b/);
expect(scratch.description).toContain("not a recurring schedule");
expect(outcome.type).toBe("string");
expect(outcome.enum).toEqual(["no_change", "progress", "done", "blocked", "needs_attention"]);
expect(priority.type).toBe("string");
expect(priority.enum).toEqual(["low", "normal", "high"]);
expect(outcome).not.toHaveProperty("anyOf");
expect(priority).not.toHaveProperty("anyOf");
});
it("accepts a quiet no-change outcome without claiming persistence", async () => {
const tool = resolveHeartbeatResponseTool({ enableHeartbeatTool: true });
assert(tool);
const result = await tool.execute("call-1", {
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
});
expect(tool.name).toBe(HEARTBEAT_RESPONSE_TOOL_NAME);
const details = result.details as HeartbeatResponseDetails;
expect(details.status).toBe("accepted");
expect(details.outcome).toBe("no_change");
expect(details.notify).toBe(false);
expect(details.summary).toBe("Nothing needs attention.");
expect(result.content).toEqual([
expect.objectContaining({ text: expect.stringContaining('"status": "accepted"') }),
]);
});
it("preserves the configured reply instructions when the run does not accept responses", async () => {
const tool = resolveHeartbeatResponseTool({ offerHeartbeatTool: true });
assert(tool);
await expect(
tool.execute("call-1", { outcome: "no_change", notify: false, summary: "Quiet." }),
).rejects.toThrow(
"heartbeat_respond is not accepted in this run; follow the configured reply instructions instead",
);
});
it("rejects repeated heartbeat responses from the same tool instance", async () => {
// A heartbeat turn has one final outcome; accepting multiple writes would
// make notification delivery ambiguous.
const tool = resolveHeartbeatResponseTool({ enableHeartbeatTool: true });
assert(tool);
await tool.execute("call-1", {
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
});
await expect(
tool.execute("call-2", {
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
}),
).rejects.toThrow("heartbeat_respond already accepted");
});
it("captures scratch without echoing future prompt content to the model", async () => {
const tool = resolveHeartbeatResponseTool({ enableHeartbeatTool: true });
assert(tool);
const scratch = "Private monitor context that must not enter tool output.";
const result = await tool.execute("call-1", {
outcome: "progress",
notify: false,
summary: "Updated monitor context.",
scratch,
});
const details = result.details as HeartbeatResponseDetails;
expect(details.scratch).toBe(scratch);
expect(JSON.stringify(result.content)).not.toContain(scratch);
expect(JSON.stringify(details)).not.toContain(scratch);
expect(result.content).toEqual([
expect.objectContaining({ text: expect.stringContaining('"scratchPending": true') }),
]);
});
it("accepts an urgent notification without claiming persistence", async () => {
const tool = resolveHeartbeatResponseTool({ enableHeartbeatTool: true });
assert(tool);
const result = await tool.execute("call-1", {
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
nextCheck: "2026-05-01T17:00:00Z",
});
const details = result.details as HeartbeatResponseDetails;
expect(details.status).toBe("accepted");
expect(details.outcome).toBe("needs_attention");
expect(details.notify).toBe(true);
expect(details.summary).toBe("Build is blocked.");
expect(details.notificationText).toBe("Build is blocked on missing credentials.");
expect(details.priority).toBe("high");
expect(details.nextCheck).toBe("2026-05-01T17:00:00Z");
expect(result.content).toEqual([
expect.objectContaining({ text: expect.stringContaining('"status": "accepted"') }),
]);
});
it("rejects missing notify because quiet vs visible delivery must be explicit", async () => {
const tool = resolveHeartbeatResponseTool({ enableHeartbeatTool: true });
assert(tool);
await expect(
tool.execute("call-1", {
outcome: "no_change",
summary: "Nothing needs attention.",
}),
).rejects.toThrow("notify required");
});
});

View file

@ -1,152 +0,0 @@
/**
* Auto-reply heartbeat turns use this tool to accept the agent's outcome,
* notification decision, and next-check metadata exactly once per turn.
*/
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { Type } from "typebox";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
HEARTBEAT_TOOL_OUTCOMES,
HEARTBEAT_TOOL_PRIORITIES,
normalizeHeartbeatToolResponse,
} from "../../auto-reply/heartbeat-tool-response.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { assertCronJobScratchContent } from "../../cron/scratch-contract.js";
import { isHeartbeatEnabledForAgent } from "../../infra/heartbeat-summary.js";
import { readSnakeCaseParamRaw } from "../../param-key.js";
import { optionalStringEnum, stringEnum } from "../schema/string-enum.js";
import type { AnyAgentTool } from "./common.js";
import { textResult, ToolInputError } from "./common.js";
const HeartbeatResponseToolSchema = Type.Object(
{
outcome: stringEnum(HEARTBEAT_TOOL_OUTCOMES),
notify: Type.Boolean(),
summary: Type.String(),
notificationText: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
priority: optionalStringEnum(HEARTBEAT_TOOL_PRIORITIES),
nextCheck: Type.Optional(Type.String()),
scratch: Type.Optional(
Type.String({
description: "Complete replacement for heartbeat monitor prose; not a recurring schedule.",
}),
),
},
{ additionalProperties: false },
);
/** Creates the one-shot heartbeat response tool for an auto-reply turn. */
/**
* A heartbeat-enabled agent offers the tool on every turn, so heartbeat turns keep the
* session's tool definitions; only a heartbeat run accepts a response.
*/
export function resolveHeartbeatTools(
options:
| {
enableHeartbeatTool?: boolean;
forceHeartbeatTool?: boolean;
trigger?: string;
config?: OpenClawConfig;
}
| undefined,
agentId?: string,
): { enableHeartbeatTool: boolean; offerHeartbeatTool: boolean } {
const enableHeartbeatTool =
options?.enableHeartbeatTool === true ||
(options?.trigger === "heartbeat" &&
options?.config?.messages?.visibleReplies === "message_tool");
return {
enableHeartbeatTool,
offerHeartbeatTool:
enableHeartbeatTool ||
options?.forceHeartbeatTool === true ||
(options?.config !== undefined && isHeartbeatEnabledForAgent(options.config, agentId)),
};
}
/** Every turn of a heartbeat-enabled session offers the tool; only a heartbeat run accepts it. */
export function resolveHeartbeatResponseTool(options?: {
enableHeartbeatTool?: boolean;
offerHeartbeatTool?: boolean;
}): AnyAgentTool | null {
return options?.enableHeartbeatTool || options?.offerHeartbeatTool
? createHeartbeatResponseTool({ acceptsResponses: options.enableHeartbeatTool === true })
: null;
}
function createHeartbeatResponseTool(options: { acceptsResponses?: boolean } = {}): AnyAgentTool {
let recorded = false;
return {
label: "Heartbeat",
name: HEARTBEAT_RESPONSE_TOOL_NAME,
// Heartbeat prompts instruct the model to call this one-shot tool; hiding
// it behind tool search would break the heartbeat contract.
catalogMode: "direct-only",
displaySummary: "Accept heartbeat outcome/notify choice.",
description:
"Accept heartbeat result for post-turn handling. `notify=false` no visible send. `notify=true` needs concise notificationText. Scratch is monitor prose only.",
parameters: HeartbeatResponseToolSchema,
execute: async (_toolCallId, args) => {
if (options.acceptsResponses === false) {
// Offered for tool-list stability; preserve the run's configured reply mode.
throw new ToolInputError(
"heartbeat_respond is not accepted in this run; follow the configured reply instructions instead",
);
}
if (!isRecord(args)) {
throw new ToolInputError("Heartbeat response arguments required");
}
if (typeof readSnakeCaseParamRaw(args, "notify") !== "boolean") {
throw new ToolInputError("notify required");
}
if (typeof args.scratch === "string") {
try {
assertCronJobScratchContent(args.scratch);
} catch (error) {
throw new ToolInputError(error instanceof Error ? error.message : String(error));
}
}
const response = normalizeHeartbeatToolResponse(args);
if (!response) {
throw new ToolInputError(
"Invalid heartbeat response. Provide outcome, notify, and non-empty summary.",
);
}
if (recorded) {
// One heartbeat turn should produce one decision; repeated calls can
// otherwise overwrite the notify/no-notify choice.
throw new ToolInputError("heartbeat_respond already accepted for this turn");
}
recorded = true;
const { scratch, ...publicResponse } = response;
const details = { status: "accepted" as const, ...publicResponse } as typeof response & {
status: "accepted";
};
if (scratch !== undefined) {
// Keep future prompt content out of model-visible tool output and logs;
// the runner receives it through the internal result details only.
Object.defineProperty(details, "scratch", { value: scratch, enumerable: false });
}
return textResult(
JSON.stringify(
{
status: "accepted",
...publicResponse,
...(scratch !== undefined
? {
// Persistence is a runner-side CAS after the turn; do not claim
// success here. A lost race is logged and retryable next beat.
scratchPending: true,
scratchBytes: Buffer.byteLength(scratch, "utf8"),
}
: {}),
},
null,
2,
),
details,
);
},
};
}

View file

@ -6,7 +6,6 @@ import {
registerAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "../../acp/runtime/registry.js";
import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js";
import {
clearRuntimeConfigSnapshot,
getRuntimeConfigSnapshot,
@ -86,13 +85,9 @@ function observeRecoveredSpawn(result: unknown, mutatingAction: boolean) {
const recovered = observe({ toolName: "read", outcome: "success" });
const payloads = buildPayloads({
assistantTexts: [report],
isHeartbeatTrigger: true,
lastToolError: recovered.lastToolError,
});
expect(payloads.some((payload) => payload.text === report)).toBe(true);
expect(
payloads.some((payload) => getReplyPayloadMetadata(payload)?.heartbeatTerminalToolFailure),
).toBe(mutatingAction);
}
describe("sessions_spawn terminal effects", () => {
@ -109,7 +104,7 @@ describe("sessions_spawn terminal effects", () => {
args: {},
subagents: { requireAgentId: true },
},
])("keeps recovered $name rejection out of heartbeat failure", async (testCase) => {
])("classifies recovered $name rejection as not started", async (testCase) => {
const callGateway = vi.spyOn(inProcessGateway, "callInProcessGatewayTool");
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
@ -134,7 +129,7 @@ describe("sessions_spawn terminal effects", () => {
observeRecoveredSpawn(snapshotToolSearchTargetTranscriptResult(result), false);
});
it("keeps an internal argument exception out of heartbeat failure", async () => {
it("classifies an internal argument exception as not started", async () => {
const tool = createSessionsSpawnTool({ config, countActiveRuns: () => 0 });
const error = await tool
.execute("spawn", {

View file

@ -279,12 +279,6 @@ export type GetReplyOptions = {
forceCodeModeTools?: boolean;
/** Runtime tool allow-list for this turn. Empty means no tools. */
toolsAllow?: string[];
/** If true, include the heartbeat response tool for structured heartbeat outcomes. */
enableHeartbeatTool?: boolean;
/** If true, keep the heartbeat response tool available even under narrow tool profiles. */
forceHeartbeatTool?: boolean;
/** Heartbeat-transported turn that continues a conversation (its own command completion). */
continuesConversation?: boolean;
/**
* @deprecated Ignored. The tool-failure warning is delivered whenever a run ends
* without a reply and cannot be suppressed. Kept only so plugin-sdk callers that

View file

@ -10,7 +10,6 @@ import {
isContractToolResultBlock,
readToolCallName,
} from "../shared/tool-block-contract.js";
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "./heartbeat-tool-response.js";
import {
HEARTBEAT_RESPONSE_TOOL_INSTRUCTIONS,
HEARTBEAT_RESPONSE_TOOL_PROMPT,
@ -20,6 +19,9 @@ import {
} from "./heartbeat.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "./tokens.js";
// Saved transcripts retain calls to the retired response tool.
const HEARTBEAT_RESPONSE_TOOL_NAME = "heartbeat_respond";
const HEARTBEAT_TASK_PROMPT_PREFIX =
"Run the following periodic tasks (only those due based on their intervals):";
const HEARTBEAT_TASK_PROMPT_COMPLETIONS = [

View file

@ -1,74 +0,0 @@
// Heartbeat reply payload selector tests.
import { describe, expect, it } from "vitest";
import { classifyHeartbeatAgentOutcome } from "../infra/heartbeat-delivery-normalization.js";
import {
resolveHeartbeatReplyPayload,
resolveHeartbeatTerminalToolFailure,
} from "./heartbeat-reply-payload.js";
import { setReplyPayloadMetadata } from "./reply-payload.js";
import type { ReplyPayload } from "./types.js";
describe("resolveHeartbeatReplyPayload", () => {
it.each(["Spoken heartbeat.", " "])(
"selects and classifies speech-only heartbeat content: %j",
(text) => {
const payload = setReplyPayloadMetadata({ text: "" }, { tts: { tagged: true, text } });
const selected = resolveHeartbeatReplyPayload([
payload,
{ text: "private", isReasoning: true },
]);
expect(selected).toBe(text.trim() ? payload : undefined);
const outcome = classifyHeartbeatAgentOutcome({
agentRun: { agentRunFailed: false, replyPayload: selected },
useHeartbeatFailureCopy: false,
hasRelayableExecCompletion: false,
suppressUnmarkedSourceReplies: false,
responsePrefix: undefined,
ackMaxChars: 300,
});
expect(outcome).toMatchObject(
text.trim()
? { kind: "delivery", replyPayload: payload, normalized: { text: "" } }
: { kind: "ack", eventStatus: "ok-empty" },
);
},
);
it("returns a single non-array payload unchanged", () => {
const payload: ReplyPayload = { text: "HEARTBEAT_OK" };
expect(resolveHeartbeatReplyPayload(payload)).toBe(payload);
});
it("returns undefined for undefined input", () => {
expect(resolveHeartbeatReplyPayload(undefined)).toBeUndefined();
expect(resolveHeartbeatTerminalToolFailure(undefined)).toBeUndefined();
});
it("resolves terminal tool-failure metadata independently of payload order", () => {
const heartbeat = setReplyPayloadMetadata(
{ text: "HEARTBEAT_OK" },
{ heartbeatTerminalToolFailure: { toolName: "message" } },
);
const warning: ReplyPayload = { text: "Message failed", isError: true };
expect(resolveHeartbeatTerminalToolFailure([heartbeat, warning])).toEqual({
toolName: "message",
});
});
it("skips a trailing reasoning payload and returns the assistant answer", () => {
const answer: ReplyPayload = { text: "HEARTBEAT_OK" };
const reasoning: ReplyPayload = {
text: "The message is an OpenClaw heartbeat poll. I should check recent chat...",
isReasoning: true,
};
expect(resolveHeartbeatReplyPayload([answer, reasoning])).toBe(answer);
});
it("returns undefined for a scalar reasoning payload", () => {
const reasoning: ReplyPayload = {
text: "Considering whether the heartbeat needs a reply...",
isReasoning: true,
};
expect(resolveHeartbeatReplyPayload(reasoning)).toBeUndefined();
});
});

View file

@ -1,42 +0,0 @@
import {
hasOutboundReplyContent,
isReasoningReplyPayload,
} from "openclaw/plugin-sdk/reply-payload";
import { getReplyPayloadMetadata } from "./reply-payload.js";
import type { ReplyPayload } from "./types.js";
export type HeartbeatTerminalToolFailure = {
toolName: string;
};
/** Resolve structured terminal tool-failure state carried by an agent reply. */
export function resolveHeartbeatTerminalToolFailure(
replyResult: ReplyPayload | ReplyPayload[] | undefined,
): HeartbeatTerminalToolFailure | undefined {
if (!replyResult) {
return undefined;
}
const payloads = Array.isArray(replyResult) ? replyResult : [replyResult];
const payload = payloads.findLast(
(entry) => entry && getReplyPayloadMetadata(entry)?.heartbeatTerminalToolFailure,
);
return payload ? getReplyPayloadMetadata(payload)?.heartbeatTerminalToolFailure : undefined;
}
/**
* Pick the last outbound-capable reply, excluding flagged and text-prefixed reasoning.
* Scalar replies intentionally need no outbound-content check.
*/
export function resolveHeartbeatReplyPayload(
replyResult: ReplyPayload | ReplyPayload[] | undefined,
): ReplyPayload | undefined {
if (!replyResult) {
return undefined;
}
if (!Array.isArray(replyResult)) {
return isReasoningReplyPayload(replyResult) ? undefined : replyResult;
}
return replyResult.findLast(
(payload) => payload && !isReasoningReplyPayload(payload) && hasOutboundReplyContent(payload),
);
}

View file

@ -1,30 +0,0 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it } from "vitest";
import {
createHeartbeatToolResponsePayload,
selectHeartbeatToolResponse,
} from "./heartbeat-tool-response.js";
import { getReplyPayloadMetadata } from "./reply-payload.js";
describe("heartbeat scratch proposal resolution", () => {
it("lets a later heartbeat response clear an earlier scratch proposal", () => {
const first = createHeartbeatToolResponsePayload({
outcome: "progress",
notify: false,
summary: "first",
scratch: "stale scratch",
});
const corrected = createHeartbeatToolResponsePayload({
outcome: "no_change",
notify: false,
summary: "corrected",
});
const selected = expectDefined(
selectHeartbeatToolResponse([first, corrected]),
"expected the corrected heartbeat response",
);
expect(selected.response.summary).toBe("corrected");
expect(getReplyPayloadMetadata(selected.payload)?.heartbeatScratchProposal).toBeUndefined();
});
});

View file

@ -1,118 +0,0 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce";
import { assertCronJobScratchContent } from "../cron/scratch-contract.js";
import { readTrimmedStringAlias } from "../utils/string-readers.js";
import { setReplyPayloadMetadata, type ReplyPayload } from "./reply-payload.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
/** Tool name used by heartbeat runs to report visible or silent progress. */
export const HEARTBEAT_RESPONSE_TOOL_NAME = "heartbeat_respond";
const HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY = "openclawHeartbeatResponse";
/** Allowed heartbeat response outcomes. */
export const HEARTBEAT_TOOL_OUTCOMES = [
"no_change",
"progress",
"done",
"blocked",
"needs_attention",
] as const;
type HeartbeatToolOutcome = (typeof HEARTBEAT_TOOL_OUTCOMES)[number];
/** Allowed heartbeat notification priorities. */
export const HEARTBEAT_TOOL_PRIORITIES = ["low", "normal", "high"] as const;
type HeartbeatToolPriority = (typeof HEARTBEAT_TOOL_PRIORITIES)[number];
/** Normalized response emitted by the heartbeat response tool. */
export type HeartbeatToolResponse = {
outcome: HeartbeatToolOutcome;
notify: boolean;
summary: string;
notificationText?: string;
reason?: string;
priority?: HeartbeatToolPriority;
nextCheck?: string;
/** Complete replacement for the current heartbeat monitor's private scratch. */
scratch?: string;
};
const OUTCOMES = new Set<string>(HEARTBEAT_TOOL_OUTCOMES);
const PRIORITIES = new Set<string>(HEARTBEAT_TOOL_PRIORITIES);
/** Validate and normalize unknown heartbeat tool output. */
export function normalizeHeartbeatToolResponse(value: unknown): HeartbeatToolResponse | undefined {
if (!isRecord(value)) {
return undefined;
}
const outcome = readString(value.outcome);
const notify = typeof value.notify === "boolean" ? value.notify : undefined;
const summary = readString(value.summary);
if (!outcome || !OUTCOMES.has(outcome) || notify === undefined || !summary) {
return undefined;
}
const priority = readString(value.priority);
const notificationText = readTrimmedStringAlias(value, ["notificationText", "notification_text"]);
const reason = readString(value.reason);
const nextCheck = readTrimmedStringAlias(value, ["nextCheck", "next_check"]);
const scratch = typeof value.scratch === "string" ? value.scratch : undefined;
if (scratch !== undefined) {
try {
assertCronJobScratchContent(scratch);
} catch {
return undefined;
}
}
return {
outcome: outcome as HeartbeatToolOutcome,
notify,
summary,
...(notificationText ? { notificationText } : {}),
...(reason ? { reason } : {}),
...(priority && PRIORITIES.has(priority)
? { priority: priority as HeartbeatToolPriority }
: {}),
...(nextCheck ? { nextCheck } : {}),
...(scratch !== undefined ? { scratch } : {}),
};
}
/** Resolve the user-visible notification text for a heartbeat response. */
export function getHeartbeatToolNotificationText(response: HeartbeatToolResponse): string {
return response.notify ? (response.notificationText ?? response.summary).trim() : "";
}
/** Store public heartbeat response metadata while keeping scratch process-private. */
export function createHeartbeatToolResponsePayload(response: HeartbeatToolResponse): ReplyPayload {
const { scratch, ...publicResponse } = response;
const payload: ReplyPayload = {
text: response.notify ? getHeartbeatToolNotificationText(response) : HEARTBEAT_TOKEN,
channelData: {
[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY]: publicResponse,
},
};
if (scratch !== undefined) {
setReplyPayloadMetadata(payload, { heartbeatScratchProposal: scratch });
}
return payload;
}
/** Select the newest valid response and retain its private metadata carrier. */
export function selectHeartbeatToolResponse(
replyResult: ReplyPayload | ReplyPayload[] | undefined,
): { response: HeartbeatToolResponse; payload: ReplyPayload } | undefined {
if (!replyResult) {
return undefined;
}
const payloads = Array.isArray(replyResult) ? replyResult : [replyResult];
for (let idx = payloads.length - 1; idx >= 0; idx -= 1) {
const payload = payloads[idx];
const response = normalizeHeartbeatToolResponse(
payload?.channelData?.[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY],
);
if (response && payload) {
return { response, payload };
}
}
return undefined;
}

View file

@ -292,8 +292,6 @@ export type ReplyPayloadMetadata = {
contextFreeCommand?: true;
/** Host-owned acknowledgement after this final payload is confirmed delivered. */
onFinalDeliverySuccess?: () => void;
/** Host-projected monitoring final; notification policy already normalized its text. */
heartbeatReply?: true;
/** Exact key for replacing a runtime-owned assistant row after media materialization. */
assistantTranscriptIdempotencyKey?: string;
/** Original session-writer claim that must still hold at final delivery. */
@ -373,12 +371,6 @@ export type ReplyPayloadMetadata = {
hostNotice?: true;
/** Runtime acknowledgment of a committed queue decision, independent of active reply delivery. */
queueAdmissionNotice?: true;
/** Unresolved mutating tool failure that makes a heartbeat run terminally failed. */
heartbeatTerminalToolFailure?: {
toolName: string;
};
/** Private scratch must survive reply copies without becoming serializable channel data. */
heartbeatScratchProposal?: string;
};
// Source Gateways and native plugin SDK chunks must share the same payload identity.

View file

@ -172,8 +172,6 @@ export async function runEmbeddedFallbackCandidate(
toolProgressDetail: turn.toolProgressDetail,
// Marks reply-owned policy; final attempt preparation binds its concrete route.
toolAuthorityFingerprint: turn.replyOperation?.toolAuthorityFingerprint,
enableHeartbeatTool: turn.opts?.enableHeartbeatTool,
forceHeartbeatTool: turn.opts?.forceHeartbeatTool,
deferTerminalLifecycle: true,
onAttemptStart: lifecycleBackstop.beginAttempt,
onAttemptTerminalReplaced: lifecycleBackstop.replaceAttemptTerminal,

View file

@ -1,13 +1,11 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it } from "vitest";
import { buildEmbeddedRunPayloads } from "../../agents/embedded-agent-runner/run/payloads.js";
import type { ChannelThreadingAdapter } from "../../channels/plugins/types.public.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { selectHeartbeatToolResponse } from "../heartbeat-tool-response.js";
import {
getReplyPayloadMetadata,
markReplyPayloadForSourceSuppressionDelivery,
@ -64,54 +62,6 @@ async function expectBuiltPayloads({
expected.forEach((payload, index) => expectFields(replyPayloads[index], payload));
}
describe("heartbeat reply scratch", () => {
it.each([
{ notify: false, proposals: [" PRIVATE_SCRATCH\n\n- keep exact spacing \n"] },
{ notify: true, proposals: [""] },
{ notify: false, proposals: ["old proposal", "new proposal"] },
{ notify: false, proposals: ["old proposal", undefined] },
])(
"preserves the latest private decision through embedded and final payloads: %j",
async ({ notify, proposals }) => {
const responses = proposals.map((scratch, index) => ({
outcome: "done" as const,
notify,
summary: `Monitor checked ${index + 1}.`,
...(scratch !== undefined ? { scratch } : {}),
}));
const payloads = responses.flatMap((heartbeatToolResponse) =>
buildEmbeddedRunPayloads({
assistantTexts: [],
lastAssistant: undefined,
sessionKey: "agent:main:main",
isHeartbeatTrigger: true,
heartbeatToolResponse,
}),
);
const expected = proposals.at(-1);
const embedded = expectDefined(selectHeartbeatToolResponse(payloads), "embedded response");
expect(getReplyPayloadMetadata(embedded.payload)?.heartbeatScratchProposal).toBe(expected);
const { replyPayloads } = await buildTestReplyPayloads({ isHeartbeat: true, payloads });
expect(replyPayloads).toHaveLength(proposals.length);
const selected = expectDefined(selectHeartbeatToolResponse(replyPayloads), "final response");
expect(getReplyPayloadMetadata(selected.payload)?.heartbeatScratchProposal).toBe(expected);
expect(selected.response).toEqual({
outcome: "done",
notify,
summary: `Monitor checked ${proposals.length}.`,
});
const serialized = JSON.stringify(replyPayloads);
expect(serialized).not.toContain('"scratch"');
for (const scratch of proposals) {
if (scratch) {
expect(serialized).not.toContain(JSON.stringify(scratch));
}
}
},
);
});
function expectFields(value: unknown, expected: Record<string, unknown>): void {
if (!value || typeof value !== "object") {
throw new Error("expected fields object");

View file

@ -184,7 +184,6 @@ export function buildFallbackCandidateTurnParams(params: AgentFallbackCandidateC
disableTools: turn.opts?.disableTools,
forceRestartSafeTools: turn.opts?.forceRestartSafeTools,
forceCodeModeTools: turn.opts?.forceCodeModeTools,
continuesConversation: turn.opts?.continuesConversation,
bootstrapContextMode: turn.opts?.bootstrapContextMode,
bootstrapContextRunKind: params.bootstrapContextRunKind,
images: params.currentTurnImages.images,

View file

@ -100,8 +100,6 @@ it("keeps heartbeat cleanup options out of a deferred queued user turn", async (
isHeartbeat: true,
useHeartbeatFailureCopy: true,
cleanupBundleMcpOnRunEnd: true,
enableHeartbeatTool: true,
forceHeartbeatTool: true,
sourceReplyDeliveryMode: "message_tool_only",
bootstrapContextMode: "lightweight",
},
@ -160,13 +158,11 @@ it("keeps heartbeat cleanup options out of a deferred queued user turn", async (
opts: { isHeartbeat: false },
});
expect([
retriedExecution.opts?.enableHeartbeatTool,
retriedExecution.opts?.forceHeartbeatTool,
retriedExecution.opts?.useHeartbeatFailureCopy,
retriedExecution.opts?.cleanupBundleMcpOnRunEnd,
retriedExecution.opts?.bootstrapContextMode,
retriedExecution.opts?.sourceReplyDeliveryMode,
]).toEqual(Array(6).fill(undefined));
]).toEqual(Array(4).fill(undefined));
} finally {
releaseFirst.resolve();
heartbeatOperation.complete();

View file

@ -1,14 +1,14 @@
// Reply and heartbeat turns get the delivering Telegram account's formatting contract once.
// Replies get the delivering Telegram account's formatting contract once.
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { heartbeatRunnerTelegramPlugin } from "../../../test/helpers/infra/heartbeat-runner-channel-plugins.js";
import * as embeddedAgent from "../../agents/embedded-agent.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { runHeartbeatOnce } from "../../infra/heartbeat-runner.js";
import { seedMainSessionStore } from "../../infra/heartbeat-runner.test-utils.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { resetSystemEventsForTest } from "../../infra/system-events.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import {
createOpenClawTestState,
type OpenClawTestState,
@ -26,7 +26,7 @@ beforeEach(() => {
pluginId: "telegram",
source: "test",
plugin: {
...heartbeatRunnerTelegramPlugin,
...createChannelTestPluginBase({ id: "telegram" }),
agentPrompt: {
inboundFormattingHints: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
params.cfg.channels?.telegram?.accounts?.[params.accountId ?? ""]?.richMessages
@ -57,7 +57,6 @@ async function setup(label: string) {
skipBootstrap: true,
model: { primary: "mock-openai/gpt-5.6-luna" },
models: { "mock-openai/gpt-5.6-luna": { agentRuntime: { id: "openclaw" } } },
heartbeat: { every: "5m", target: "last" },
},
},
channels: {
@ -80,7 +79,7 @@ async function setup(label: string) {
},
}));
const lastPrompt = () => runAgent.mock.calls.at(-1)?.[0].extraSystemPrompt ?? "";
return { cfg, storePath, lastPrompt };
return { cfg, lastPrompt };
}
function expectContractOnce(prompt: string, markup: string) {
@ -106,26 +105,3 @@ it("gives a Telegram reply on the rich account its contract once", async () => {
);
expectContractOnce(lastPrompt(), "markdown_telegram_rich");
});
it("gives a heartbeat delivered to Telegram the delivering account's contract once", async () => {
const { cfg, storePath, lastPrompt } = await setup("heartbeat-delivery-format");
const sessionKey = await seedMainSessionStore(storePath, cfg, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: "-100155462274",
lastAccountId: "rich",
});
enqueueSystemEvent("Reminder: post the status table", {
sessionKey,
contextKey: "cron:status",
});
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
sessionKey,
source: "cron",
reason: "cron:status",
});
expect(result.status).toBe("ran");
expectContractOnce(lastPrompt(), "markdown_telegram_rich");
});

View file

@ -1,6 +1,5 @@
import { expect, it } from "vitest";
import { setReplyPayloadMetadata, stripReplyPayloadResponsePrefix } from "../reply-payload.js";
import { HEARTBEAT_TOKEN } from "../tokens.js";
import { stripReplyPayloadResponsePrefix } from "../reply-payload.js";
import { normalizeReplyPayloadOutcome } from "./normalize-reply.js";
it("preserves truncated control-looking text and attached media", () => {
@ -39,16 +38,3 @@ it.each([
);
}
});
it("preserves a host-approved heartbeat acknowledgment while retaining channel transforms", () => {
const payload = setReplyPayloadMetadata({ text: HEARTBEAT_TOKEN }, { heartbeatReply: true });
expect(normalizeReplyPayloadOutcome(payload)).toEqual({ kind: "deliver", payload });
expect(normalizeReplyPayloadOutcome({ ...payload })).toEqual({
kind: "suppress",
reason: "heartbeat",
});
expect(normalizeReplyPayloadOutcome(payload, { transformReplyPayload: () => null })).toEqual({
kind: "suppress",
reason: "channel_transform",
});
});

View file

@ -79,45 +79,42 @@ export function normalizeReplyPayloadOutcome(
}
let text = payload.text;
// Monitoring already applied its configured acknowledgment and error-text policy.
if (!getReplyPayloadMetadata(payload)?.heartbeatReply) {
if (getReplyPayloadMetadata(payload)?.silentReply === true) {
if (!hasContent("") && !hasSpeechContent) {
return suppress("silent");
}
text = "";
}
if (text && !trimmed) {
// Keep empty text when media exists so media-only replies still send.
text = "";
}
if (text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) {
opts.onHeartbeatStrip?.();
}
if (stripped.shouldSkip && !hasContent(stripped.text)) {
return suppress("heartbeat");
}
text = stripped.text;
}
if (text && isInternalFormattingArtifact(text) && !hasContent("")) {
if (getReplyPayloadMetadata(payload)?.silentReply === true) {
if (!hasContent("") && !hasSpeechContent) {
return suppress("silent");
}
text = "";
}
if (text && !trimmed) {
// Keep empty text when media exists so media-only replies still send.
text = "";
}
if (text) {
text = payload.isError
? renderUserFacingText(text, {
errorContext: true,
conversationContext: opts.conversationContext,
})
: sanitizeUserFacingText(text, { conversationContext: opts.conversationContext });
if (text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) {
opts.onHeartbeatStrip?.();
}
if (!hasContent(text) && !hasSpeechContent) {
return suppress("empty");
if (stripped.shouldSkip && !hasContent(stripped.text)) {
return suppress("heartbeat");
}
text = stripped.text;
}
if (text && isInternalFormattingArtifact(text) && !hasContent("")) {
return suppress("silent");
}
if (text) {
text = payload.isError
? renderUserFacingText(text, {
errorContext: true,
conversationContext: opts.conversationContext,
})
: sanitizeUserFacingText(text, { conversationContext: opts.conversationContext });
}
if (!hasContent(text) && !hasSpeechContent) {
return suppress("empty");
}
let enrichedPayload: ReplyPayload = copyReplyPayloadMetadata(payload, { ...payload, text });

View file

@ -2,8 +2,6 @@ import type {
ContextEngineTurnOutboxPayload,
ContextEngineTurnOutboxWorkerOperations,
} from "../../agents/harness/context-engine-turn-outbox.js";
import type { HeartbeatOutcomeRow } from "../../infra/heartbeat-outcome-store.kernel.js";
import type { HeartbeatOutcomeWorkerOperations } from "../../infra/heartbeat-outcome-store.worker.js";
import type { MessageToolRunOutcomeInsert } from "../../infra/message-tool-run-outcome-store.kernel.js";
import type {
SqliteTrajectoryRuntimeAppend,
@ -41,8 +39,6 @@ export type SessionActorMemorySideEffectsWrites = Omit<
OutboxOperations,
keyof SessionActorMemorySideEffectsReads
> & {
"session.heartbeat.persist": HeartbeatOutcomeWorkerOperations["persist"];
"session.heartbeat.claim": HeartbeatOutcomeWorkerOperations["claim"];
"session.messageToolOutcome.record": { input: MessageToolRunOutcomeInsert; output: void };
"session.trajectory.append": { input: SqliteTrajectoryRuntimeAppend; output: void };
};
@ -81,7 +77,6 @@ export type SessionActorMemoryTrajectoryRow = {
/** These records belong to the logical session and survive transcript-window rotation. */
export type SessionActorMemorySideEffectsState = {
outbox: Map<string, SessionActorMemoryOutboxRow>;
heartbeatOutcome?: HeartbeatOutcomeRow;
messageToolOutcomes: Array<MessageToolRunOutcomeInsert & { id: number }>;
trajectory: Map<string, SessionActorMemoryTrajectoryRow[]>;
};
@ -96,7 +91,6 @@ export function cloneSessionActorMemorySideEffects(
): SessionActorMemorySideEffectsState {
return {
outbox: new Map(state.outbox),
heartbeatOutcome: state.heartbeatOutcome,
messageToolOutcomes: [...state.messageToolOutcomes],
trajectory: new Map(state.trajectory),
};

View file

@ -3,10 +3,6 @@ import { describe, expect, it, vi } from "vitest";
import { openContextEngineTurnOutboxWorkerStore } from "../../agents/harness/context-engine-turn-outbox-store.js";
import { drainContextEngineTurnOutbox } from "../../agents/harness/context-engine-turn-outbox.js";
import type { ContextEngine } from "../../context-engine/types.js";
import {
claimHeartbeatOutcomeForRun,
persistHeartbeatOutcome,
} from "../../infra/heartbeat-outcome-store.js";
import { recordMessageToolRunOutcome } from "../../infra/message-tool-run-outcome-store.js";
import { resolveIncognitoOpenClawAgentSqlitePath } from "../../state/openclaw-agent-db.paths.js";
import { createSqliteTrajectoryRuntimeSink } from "../../trajectory/runtime-store-writer.js";
@ -28,7 +24,6 @@ import { failSessionActorMemoryPublication } from "./session-actor-memory-public
import { mutateSessionActorMemorySideEffects } from "./session-actor-memory-side-effects.js";
import { createSessionActorMemoryState } from "./session-actor-memory-state.js";
import type { SessionActorMemoryStorageContext } from "./session-actor-memory-storage-context.js";
import { runWithSessionActorStorage } from "./session-actor-storage-binding.js";
import type {
SessionActorStorageOutcome,
SessionActorStorageWrites,
@ -136,34 +131,6 @@ async function trajectorySink(scope: Scope, sessionActor?: Binding) {
return sink;
}
function heartbeat(
scope: Scope,
summary: string,
occurredAt = 1,
taskNames?: string[],
outcome: "progress" | "done" = "progress",
) {
return persistHeartbeatOutcome({
...scope,
runSessionKey: scope.sessionKey,
occurredAt,
response: { outcome, notify: false, summary },
taskNames,
});
}
const claim = (scope: Scope, runId: string) => claimHeartbeatOutcomeForRun({ ...scope, runId });
const recordOutcome = (scope: Scope, runId: string, occurredAt = 1) =>
recordMessageToolRunOutcome({
...scope,
runId,
provider: "test",
model: "test",
outcome: "mute",
runStatus: "completed",
occurredAt,
});
async function publicationFails(scope: Scope, operation: () => Promise<unknown>) {
const stop = failSessionActorMemoryPublication(scope.sessionKey);
try {
@ -199,16 +166,6 @@ function trajectory(sessionId: string, text: string, seq: number): TrajectoryEve
}
describe("memory session side-data consumers", () => {
it("reports committed heartbeat publication failures without releasing the claim", async () => {
const { scope } = await fixture();
await publicationFails(scope, () => heartbeat(scope, "saved"));
await publicationFails(scope, () => claim(scope, "first"));
expect(await claim(scope, "other")).toBeUndefined();
expect(await claim(scope, "first")).toMatchObject({
summary: "saved",
});
});
it("reports a committed message-tool outcome publication failure after one write", async () => {
const { scope, actor } = await fixture();
const sequence = actor.snapshot(authority)!.version.sequence;
@ -266,9 +223,16 @@ describe("memory session side-data consumers", () => {
updatedAt: 1,
incognito: true,
});
await heartbeat(scope, "unbound");
expect((await claim(scope, "user"))?.summary).toBe("unbound");
await recordOutcome(scope, "user");
const outcome = {
...scope,
runId: "user",
provider: "test",
model: "test",
outcome: "mute" as const,
runStatus: "completed" as const,
occurredAt: 1,
};
await recordMessageToolRunOutcome(outcome);
const appended = await appendTranscriptMessage(scope, {
message: { role: "user", content: "unbound turn" },
now: 1,
@ -295,8 +259,7 @@ describe("memory session side-data consumers", () => {
{ agentId: scope.agentId, path: scope.storePath },
scope.sessionKey,
);
await expect(recordOutcome(scope, "user")).rejects.toThrow("incognito");
expect(await claim(scope, "late")).toBeUndefined();
await expect(recordMessageToolRunOutcome(outcome)).rejects.toThrow("incognito");
expect(await loadSqliteTrajectoryRuntimeEvents(scope)).toEqual([]);
expect(loadSqliteTrajectoryRuntimeEventRowsSync(scope)).toEqual([]);
expect(
@ -306,19 +269,6 @@ describe("memory session side-data consumers", () => {
).toBeUndefined();
});
it("claims heartbeat context once per run, sees replacements, and keeps returned data detached", async () => {
const { scope, binding } = await fixture();
await runWithSessionActorStorage(binding, () => heartbeat(scope, "first", 1, ["task"]));
const [winner, other] = await Promise.all([claim(scope, "run-one"), claim(scope, "run-two")]);
expect(winner?.summary).toBe("first");
expect(other).toBeUndefined();
winner!.taskNames.push("mutated");
expect((await claim(scope, "run-one"))?.taskNames).toEqual(["task"]);
await heartbeat(scope, "replacement", 2, undefined, "done");
expect((await claim(scope, "run-two"))?.summary).toBe("replacement");
await recordOutcome(scope, "run-two", 2);
});
it("recovers ordered accepted turns and allows plugin callbacks to await the same actor", async () => {
const { scope, actor, storage, binding, turn } = await fixture();
const store = outbox(scope, binding);

View file

@ -1,4 +1,3 @@
import type { HeartbeatOutcomeRow } from "../../infra/heartbeat-outcome-store.kernel.js";
import {
mutateSessionActorMemoryOutbox,
readSessionActorMemoryOutbox,
@ -32,42 +31,6 @@ export function mutateSessionActorMemorySideEffects(
) {
const key = context.state.hot.target.sessionKey;
switch (command.type) {
case "session.heartbeat.persist": {
if (command.input.session_key !== key) {
throw new Error("Heartbeat outcome does not target this session actor");
}
if (context.state.hot.entry) {
const row: HeartbeatOutcomeRow = {
...command.input,
next_check: command.input.next_check ?? null,
priority: command.input.priority ?? null,
response_reason: command.input.response_reason ?? null,
task_names_json: command.input.task_names_json ?? null,
wake_reason: command.input.wake_reason ?? null,
wake_source: command.input.wake_source ?? null,
context_run_id: null,
context_claimed_at: null,
};
context.state.heartbeatOutcome = row;
}
return undefined;
}
case "session.heartbeat.claim": {
if (command.input.sessionKey !== key) {
throw new Error("Heartbeat outcome does not target this session actor");
}
const row = context.state.heartbeatOutcome;
if (!row || (row.context_run_id !== null && row.context_run_id !== command.input.runId)) {
return undefined;
}
const claimed = {
...row,
context_run_id: command.input.runId,
context_claimed_at: row.context_claimed_at ?? Date.now(),
};
context.state.heartbeatOutcome = claimed;
return claimed;
}
case "session.messageToolOutcome.record": {
if (command.input.session_key !== key || command.input.agent_id !== context.agentId) {
throw new Error("Message-tool outcome does not target this session actor");

Some files were not shown because too many files have changed in this diff Show more