fix: prevent persisted turn replay

This commit is contained in:
Peter Steinberger
2026-05-04 05:47:46 +01:00
parent 7be29b2801
commit 0fcf2c64c0
4 changed files with 163 additions and 3 deletions

View File

@@ -87,6 +87,7 @@ Docs: https://docs.openclaw.ai
- MCP/plugin tools: apply global `tools.profile`, `tools.alsoAllow`, and `tools.deny` policy while exposing plugin tools over the standalone MCP bridge, so ACP clients do not see policy-hidden plugin tools or miss opt-in optional tools. Thanks @vincentkoc.
- Plugin tools: honor explicit tool denylists while selecting plugin tool runtimes, so denied plugin tools are not materialized for direct command or gateway surfaces before later policy filtering. Thanks @vincentkoc.
- Plugin tools: filter factory-returned tools by manifest per-tool optional policy, so optional sibling tools from a shared runtime factory stay hidden unless explicitly allowed. Thanks @vincentkoc.
- Agents/transcripts: retry context-overflow compaction from the current transcript only after the inbound user turn was actually persisted, and keep WebChat agent-run live delivery from writing duplicate Pi-managed assistant turns. Fixes #76424.
- Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946.
- Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc.
- Gateway/install: keep `.env`-managed values in the macOS LaunchAgent env file while still tracking `OPENCLAW_SERVICE_MANAGED_ENV_KEYS`, so regenerated services do not boot without managed auth/provider keys. Fixes #75374.

View File

@@ -98,6 +98,89 @@ describe("overflow compaction in run loop", () => {
expect(result.meta.error).toBeUndefined();
});
it("continues from transcript after compaction when the current inbound message was persisted", async () => {
const overflowError = makeOverflowError();
mockedRunEmbeddedAttempt
.mockImplementationOnce(async (attemptParams) => {
(
attemptParams as {
onUserMessagePersisted?: (message: { role: "user"; content: string }) => void;
}
).onUserMessagePersisted?.({ role: "user", content: baseParams.prompt });
return makeAttemptResult({ promptError: overflowError });
})
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
mockedCompactDirect.mockResolvedValueOnce(
makeCompactionSuccess({
summary: "Compacted session",
firstKeptEntryId: "entry-5",
tokensBefore: 150000,
}),
);
const result = await runEmbeddedPiAgent({
...baseParams,
currentMessageId: "telegram-msg-51024",
});
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(mockedRunEmbeddedAttempt).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
prompt: expect.stringContaining("Continue from the current transcript"),
suppressNextUserMessagePersistence: true,
}),
);
expect(mockedRunEmbeddedAttempt).not.toHaveBeenNthCalledWith(
2,
expect.objectContaining({ prompt: baseParams.prompt }),
);
expect(result.meta.error).toBeUndefined();
});
it("does not suppress the next user turn when precheck overflow never persisted it", async () => {
const overflowError = makeOverflowError(
"Context overflow: prompt too large for the model (precheck).",
);
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(
makeAttemptResult({
promptError: overflowError,
promptErrorSource: "precheck",
preflightRecovery: { route: "compact_only" },
}),
)
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
mockedCompactDirect.mockResolvedValueOnce(
makeCompactionSuccess({
summary: "Compacted before prompt submission",
firstKeptEntryId: "entry-5",
tokensBefore: 150000,
}),
);
const result = await runEmbeddedPiAgent({
...baseParams,
currentMessageId: "telegram-msg-51025",
});
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(mockedRunEmbeddedAttempt).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
prompt: baseParams.prompt,
suppressNextUserMessagePersistence: false,
}),
);
expect(result.meta.error).toBeUndefined();
});
it("retries after successful compaction on likely-overflow promptError variants", async () => {
const overflowHintError = new Error("Context window exceeded: requested 12000 tokens");

View File

@@ -805,6 +805,16 @@ export async function runEmbeddedPiAgent(
const rateLimitProfileRotationLimit = resolveRateLimitProfileRotationLimit(params.config);
let activeSessionId = params.sessionId;
let activeSessionFile = params.sessionFile;
let suppressNextUserMessagePersistence = params.suppressNextUserMessagePersistence ?? false;
let lastPersistedCurrentMessageId: string | number | undefined;
const onUserMessagePersisted: RunEmbeddedPiAgentParams["onUserMessagePersisted"] = (
message,
) => {
if (params.currentMessageId !== undefined) {
lastPersistedCurrentMessageId = params.currentMessageId;
}
params.onUserMessagePersisted?.(message);
};
const maybeEscalateRateLimitProfileFallback = (params: {
failoverProvider: string;
failoverModel: string;
@@ -1170,8 +1180,8 @@ export async function runEmbeddedPiAgent(
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature:
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1],
suppressNextUserMessagePersistence: params.suppressNextUserMessagePersistence,
onUserMessagePersisted: params.onUserMessagePersisted,
suppressNextUserMessagePersistence,
onUserMessagePersisted,
});
const attempt = normalizeEmbeddedRunAttemptResult(rawAttempt);
@@ -1634,6 +1644,12 @@ export async function runEmbeddedPiAgent(
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);
if (preflightRecovery?.source === "mid-turn") {
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
} else if (
params.currentMessageId !== undefined &&
params.currentMessageId === lastPersistedCurrentMessageId
) {
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
suppressNextUserMessagePersistence = true;
}
continue;
}

View File

@@ -716,7 +716,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
);
});
it("does not persist agent media supplements when no playable media resolves", async () => {
it("does not mirror agent-run stale media final text from live delivery", async () => {
const transcriptDir = createTranscriptFixture("openclaw-chat-send-agent-stale-tts-");
const staleAudioPath = path.join(transcriptDir, "stale.mp3");
mockState.config = {
@@ -756,6 +756,66 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
(update.message as { role?: unknown }).role === "assistant",
);
expect(assistantUpdates).toEqual([]);
const transcriptLines = fs
.readFileSync(mockState.transcriptPath, "utf-8")
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
const assistantEntries = transcriptLines.filter(
(entry) =>
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
(entry as { role?: string }).role === "assistant",
);
expect(assistantEntries).toEqual([]);
});
it("does not mirror normal agent-run final text from live delivery", async () => {
const transcriptDir = createTranscriptFixture("openclaw-chat-send-agent-text-only-");
mockState.config = {
agents: {
defaults: {
workspace: transcriptDir,
},
},
};
mockState.triggerAgentRunStart = true;
mockState.dispatchedReplies = [
{
kind: "final",
payload: {
text: "It's 11:52 AM EDT.",
},
},
];
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-agent-text-only",
expectBroadcast: false,
waitFor: "dedupe",
});
const assistantUpdates = mockState.emittedTranscriptUpdates.filter(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "assistant",
);
expect(assistantUpdates).toEqual([]);
const transcriptLines = fs
.readFileSync(mockState.transcriptPath, "utf-8")
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
const assistantEntries = transcriptLines.filter(
(entry) =>
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
(entry as { role?: string }).role === "assistant",
);
expect(assistantEntries).toEqual([]);
});
it("keeps visible text on non-agent TTS final media because no model transcript exists", async () => {