mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-04 06:35:52 +00:00
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:
parent
240a90ef9f
commit
5b4f83ce86
174 changed files with 633 additions and 17473 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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
|
||||
|
|
@ -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] : []);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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" } } } },
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ export type ExecToolDefaults = ExecToolRoutingDefaults & {
|
|||
mode?: ExecMode;
|
||||
bypassHostApprovalFloors?: boolean;
|
||||
trigger?: string;
|
||||
continuesConversation?: boolean;
|
||||
pathPrepend?: string[];
|
||||
safeBins?: string[];
|
||||
strictInlineEval?: boolean;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ export type RunCliAgentParams = Pick<
|
|||
| "conversationToolPolicy"
|
||||
| "trustedInternalHandoff"
|
||||
| "trigger"
|
||||
| "continuesConversation"
|
||||
| "finalizePromptForResolvedTools"
|
||||
| "currentInboundContext"
|
||||
| "runtimeContextFragments"
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ export function createSubscriptionMock(): SubscriptionMock {
|
|||
getSourceReplyDelivered: () => undefined,
|
||||
getSourceReplyDeliveryState: () => undefined,
|
||||
endsWithSourceProgress: () => false,
|
||||
getHeartbeatToolResponse: () => undefined,
|
||||
getPendingToolMediaReply: () => null,
|
||||
getToolAutoDeliveryMediaUrls: () => [] as string[],
|
||||
hasToolMediaBlockReply: () => false,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ export type IncompleteTurnAttempt = Pick<
|
|||
| "currentAttemptCompletedAssistant"
|
||||
| "yieldDetected"
|
||||
| "didSendDeterministicApprovalPrompt"
|
||||
| "heartbeatToolResponse"
|
||||
| "toolMediaUrls"
|
||||
| "toolAudioAsVoice"
|
||||
| "toolTrustedLocalMedia"
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ export function shouldRetrySilentErrorAssistantTurn(params: {
|
|||
| "clientToolCalls"
|
||||
| "yieldDetected"
|
||||
| "didSendDeterministicApprovalPrompt"
|
||||
| "heartbeatToolResponse"
|
||||
| "lastToolError"
|
||||
| "toolMediaUrls"
|
||||
| "toolAudioAsVoice"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue