Skip to content

Commit 814e0fb

Browse files
fix(heartbeat): suppress stream-error placeholders
1 parent c6ade83 commit 814e0fb

2 files changed

Lines changed: 74 additions & 8 deletions

File tree

src/infra/heartbeat-runner.tool-response.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import fs from "node:fs/promises";
33
import path from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
56
import {
67
createHeartbeatToolResponsePayload,
78
type HeartbeatToolResponse,
@@ -345,6 +346,36 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
345346
});
346347
});
347348

349+
it("suppresses internal stream-error fallback placeholders before heartbeat delivery", async () => {
350+
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
351+
const cfg = createConfig({ tmpDir, storePath });
352+
await seedMainSessionStore(storePath, cfg, {
353+
lastChannel: "telegram",
354+
lastProvider: "telegram",
355+
lastTo: TELEGRAM_GROUP,
356+
});
357+
replySpy.mockResolvedValue(
358+
markReplyPayloadForSourceSuppressionDelivery({
359+
text: `${STREAM_ERROR_FALLBACK_TEXT}\n${STREAM_ERROR_FALLBACK_TEXT}`,
360+
}),
361+
);
362+
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1" });
363+
364+
const result = await runHeartbeatOnce({
365+
cfg,
366+
deps: createDeps({ sendTelegram, getReplyFromConfig: replySpy }),
367+
});
368+
369+
expect(result.status).toBe("ran");
370+
expect(sendTelegram).not.toHaveBeenCalled();
371+
expect(getLastHeartbeatEvent()).toMatchObject({
372+
status: "ok-token",
373+
channel: "telegram",
374+
silent: true,
375+
});
376+
});
377+
});
378+
348379
it("uses the heartbeat response tool prompt for auto-selected Codex model sessions", async () => {
349380
const result = await runPromptScenario({
350381
config: {

src/infra/heartbeat-runner.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { formatReasoningMessage } from "../agents/embedded-agent-utils.js";
2626
import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
2727
import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js";
2828
import { resolvePersistedSessionRuntimeId } from "../agents/session-runtime-compat.js";
29+
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
2930
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
3031
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
3132
import {
@@ -839,41 +840,67 @@ function stripLeadingHeartbeatResponsePrefix(
839840
return text.replace(prefixPattern, "");
840841
}
841842

843+
type NormalizedHeartbeatDelivery = {
844+
shouldSkip: boolean;
845+
text: string;
846+
hasMedia: boolean;
847+
isInternalPlaceholderOnly: boolean;
848+
};
849+
850+
function isStreamErrorFallbackPlaceholderOnly(text: string): boolean {
851+
let remaining = text.trim();
852+
if (!remaining) {
853+
return false;
854+
}
855+
856+
while (remaining.startsWith(STREAM_ERROR_FALLBACK_TEXT)) {
857+
remaining = remaining.slice(STREAM_ERROR_FALLBACK_TEXT.length).trimStart();
858+
}
859+
return remaining.length === 0;
860+
}
861+
842862
function normalizeHeartbeatReply(
843863
payload: ReplyPayload,
844864
responsePrefix: string | undefined,
845865
ackMaxChars: number,
846-
) {
866+
): NormalizedHeartbeatDelivery {
847867
const rawText = typeof payload.text === "string" ? payload.text : "";
848868
const textForStrip = stripLeadingHeartbeatResponsePrefix(rawText, responsePrefix);
849869
const stripped = stripHeartbeatToken(textForStrip, {
850870
mode: "heartbeat",
851871
maxAckChars: ackMaxChars,
852872
});
853873
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
854-
if (stripped.shouldSkip && !hasMedia) {
874+
const isInternalPlaceholderOnly = isStreamErrorFallbackPlaceholderOnly(stripped.text);
875+
if ((stripped.shouldSkip || isInternalPlaceholderOnly) && !hasMedia) {
855876
return {
856877
shouldSkip: true,
857878
text: "",
858879
hasMedia,
880+
isInternalPlaceholderOnly,
859881
};
860882
}
861-
let finalText = stripped.text;
883+
let finalText = isInternalPlaceholderOnly ? "" : stripped.text;
862884
if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) {
863885
finalText = `${responsePrefix} ${finalText}`;
864886
}
865-
return { shouldSkip: false, text: finalText, hasMedia };
887+
return { shouldSkip: false, text: finalText, hasMedia, isInternalPlaceholderOnly };
866888
}
867889

868890
function normalizeHeartbeatToolNotification(
869891
response: HeartbeatToolResponse,
870892
responsePrefix: string | undefined,
871-
) {
893+
): NormalizedHeartbeatDelivery {
872894
let finalText = getHeartbeatToolNotificationText(response);
873895
if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) {
874896
finalText = `${responsePrefix} ${finalText}`;
875897
}
876-
return { shouldSkip: false, text: finalText, hasMedia: false };
898+
return {
899+
shouldSkip: false,
900+
text: finalText,
901+
hasMedia: false,
902+
isInternalPlaceholderOnly: false,
903+
};
877904
}
878905

879906
type HeartbeatWakePayloadFlags = {
@@ -1927,7 +1954,12 @@ export async function runHeartbeatOnce(opts: {
19271954
? normalizeHeartbeatToolNotification(heartbeatToolResponse, responsePrefix)
19281955
: replyPayload
19291956
? normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars)
1930-
: { shouldSkip: true, text: "", hasMedia: false };
1957+
: {
1958+
shouldSkip: true,
1959+
text: "",
1960+
hasMedia: false,
1961+
isInternalPlaceholderOnly: false,
1962+
};
19311963
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
19321964
// The model should be responding with exec results, not ack tokens.
19331965
// Also, if normalized.text is empty due to token stripping but we have exec completion,
@@ -1936,6 +1968,7 @@ export async function runHeartbeatOnce(opts: {
19361968
!heartbeatToolResponse &&
19371969
hasRelayableExecCompletion &&
19381970
!normalized.text.trim() &&
1971+
!normalized.isInternalPlaceholderOnly &&
19391972
replyPayload?.text?.trim()
19401973
? replyPayload.text.trim()
19411974
: null;
@@ -1952,7 +1985,9 @@ export async function runHeartbeatOnce(opts: {
19521985
normalized.shouldSkip = false;
19531986
}
19541987
const shouldSkipMain =
1955-
normalized.shouldSkip && !normalized.hasMedia && !hasRelayableExecCompletion;
1988+
normalized.shouldSkip &&
1989+
!normalized.hasMedia &&
1990+
(!hasRelayableExecCompletion || normalized.isInternalPlaceholderOnly);
19561991
if (shouldSkipMain && reasoningPayloads.length === 0) {
19571992
await restoreHeartbeatUpdatedAt({
19581993
storePath,

0 commit comments

Comments
 (0)