Skip to content

Commit 0fcf2c6

Browse files
committed
fix: prevent persisted turn replay
1 parent 7be29b2 commit 0fcf2c6

4 files changed

Lines changed: 163 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Docs: https://docs.openclaw.ai
8787
- 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.
8888
- 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.
8989
- 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.
90+
- 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.
9091
- 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.
9192
- 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.
9293
- 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.

src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,89 @@ describe("overflow compaction in run loop", () => {
9898
expect(result.meta.error).toBeUndefined();
9999
});
100100

101+
it("continues from transcript after compaction when the current inbound message was persisted", async () => {
102+
const overflowError = makeOverflowError();
103+
104+
mockedRunEmbeddedAttempt
105+
.mockImplementationOnce(async (attemptParams) => {
106+
(
107+
attemptParams as {
108+
onUserMessagePersisted?: (message: { role: "user"; content: string }) => void;
109+
}
110+
).onUserMessagePersisted?.({ role: "user", content: baseParams.prompt });
111+
return makeAttemptResult({ promptError: overflowError });
112+
})
113+
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
114+
115+
mockedCompactDirect.mockResolvedValueOnce(
116+
makeCompactionSuccess({
117+
summary: "Compacted session",
118+
firstKeptEntryId: "entry-5",
119+
tokensBefore: 150000,
120+
}),
121+
);
122+
123+
const result = await runEmbeddedPiAgent({
124+
...baseParams,
125+
currentMessageId: "telegram-msg-51024",
126+
});
127+
128+
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
129+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
130+
expect(mockedRunEmbeddedAttempt).toHaveBeenNthCalledWith(
131+
2,
132+
expect.objectContaining({
133+
prompt: expect.stringContaining("Continue from the current transcript"),
134+
suppressNextUserMessagePersistence: true,
135+
}),
136+
);
137+
expect(mockedRunEmbeddedAttempt).not.toHaveBeenNthCalledWith(
138+
2,
139+
expect.objectContaining({ prompt: baseParams.prompt }),
140+
);
141+
expect(result.meta.error).toBeUndefined();
142+
});
143+
144+
it("does not suppress the next user turn when precheck overflow never persisted it", async () => {
145+
const overflowError = makeOverflowError(
146+
"Context overflow: prompt too large for the model (precheck).",
147+
);
148+
149+
mockedRunEmbeddedAttempt
150+
.mockResolvedValueOnce(
151+
makeAttemptResult({
152+
promptError: overflowError,
153+
promptErrorSource: "precheck",
154+
preflightRecovery: { route: "compact_only" },
155+
}),
156+
)
157+
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
158+
159+
mockedCompactDirect.mockResolvedValueOnce(
160+
makeCompactionSuccess({
161+
summary: "Compacted before prompt submission",
162+
firstKeptEntryId: "entry-5",
163+
tokensBefore: 150000,
164+
}),
165+
);
166+
167+
const result = await runEmbeddedPiAgent({
168+
...baseParams,
169+
currentMessageId: "telegram-msg-51025",
170+
});
171+
172+
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
173+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
174+
expect(mockedRunEmbeddedAttempt).toHaveBeenNthCalledWith(
175+
2,
176+
expect.objectContaining({
177+
prompt: baseParams.prompt,
178+
suppressNextUserMessagePersistence: false,
179+
}),
180+
);
181+
expect(result.meta.error).toBeUndefined();
182+
});
183+
101184
it("retries after successful compaction on likely-overflow promptError variants", async () => {
102185
const overflowHintError = new Error("Context window exceeded: requested 12000 tokens");
103186

src/agents/pi-embedded-runner/run.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,16 @@ export async function runEmbeddedPiAgent(
805805
const rateLimitProfileRotationLimit = resolveRateLimitProfileRotationLimit(params.config);
806806
let activeSessionId = params.sessionId;
807807
let activeSessionFile = params.sessionFile;
808+
let suppressNextUserMessagePersistence = params.suppressNextUserMessagePersistence ?? false;
809+
let lastPersistedCurrentMessageId: string | number | undefined;
810+
const onUserMessagePersisted: RunEmbeddedPiAgentParams["onUserMessagePersisted"] = (
811+
message,
812+
) => {
813+
if (params.currentMessageId !== undefined) {
814+
lastPersistedCurrentMessageId = params.currentMessageId;
815+
}
816+
params.onUserMessagePersisted?.(message);
817+
};
808818
const maybeEscalateRateLimitProfileFallback = (params: {
809819
failoverProvider: string;
810820
failoverModel: string;
@@ -1170,8 +1180,8 @@ export async function runEmbeddedPiAgent(
11701180
bootstrapPromptWarningSignaturesSeen,
11711181
bootstrapPromptWarningSignature:
11721182
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1],
1173-
suppressNextUserMessagePersistence: params.suppressNextUserMessagePersistence,
1174-
onUserMessagePersisted: params.onUserMessagePersisted,
1183+
suppressNextUserMessagePersistence,
1184+
onUserMessagePersisted,
11751185
});
11761186
const attempt = normalizeEmbeddedRunAttemptResult(rawAttempt);
11771187

@@ -1634,6 +1644,12 @@ export async function runEmbeddedPiAgent(
16341644
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);
16351645
if (preflightRecovery?.source === "mid-turn") {
16361646
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
1647+
} else if (
1648+
params.currentMessageId !== undefined &&
1649+
params.currentMessageId === lastPersistedCurrentMessageId
1650+
) {
1651+
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
1652+
suppressNextUserMessagePersistence = true;
16371653
}
16381654
continue;
16391655
}

src/gateway/server-methods/chat.directive-tags.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
716716
);
717717
});
718718

719-
it("does not persist agent media supplements when no playable media resolves", async () => {
719+
it("does not mirror agent-run stale media final text from live delivery", async () => {
720720
const transcriptDir = createTranscriptFixture("openclaw-chat-send-agent-stale-tts-");
721721
const staleAudioPath = path.join(transcriptDir, "stale.mp3");
722722
mockState.config = {
@@ -756,6 +756,66 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
756756
(update.message as { role?: unknown }).role === "assistant",
757757
);
758758
expect(assistantUpdates).toEqual([]);
759+
const transcriptLines = fs
760+
.readFileSync(mockState.transcriptPath, "utf-8")
761+
.split("\n")
762+
.filter(Boolean)
763+
.map((line) => JSON.parse(line) as Record<string, unknown>);
764+
const assistantEntries = transcriptLines.filter(
765+
(entry) =>
766+
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
767+
(entry as { role?: string }).role === "assistant",
768+
);
769+
expect(assistantEntries).toEqual([]);
770+
});
771+
772+
it("does not mirror normal agent-run final text from live delivery", async () => {
773+
const transcriptDir = createTranscriptFixture("openclaw-chat-send-agent-text-only-");
774+
mockState.config = {
775+
agents: {
776+
defaults: {
777+
workspace: transcriptDir,
778+
},
779+
},
780+
};
781+
mockState.triggerAgentRunStart = true;
782+
mockState.dispatchedReplies = [
783+
{
784+
kind: "final",
785+
payload: {
786+
text: "It's 11:52 AM EDT.",
787+
},
788+
},
789+
];
790+
const respond = vi.fn();
791+
const context = createChatContext();
792+
793+
await runNonStreamingChatSend({
794+
context,
795+
respond,
796+
idempotencyKey: "idem-agent-text-only",
797+
expectBroadcast: false,
798+
waitFor: "dedupe",
799+
});
800+
801+
const assistantUpdates = mockState.emittedTranscriptUpdates.filter(
802+
(update) =>
803+
typeof update.message === "object" &&
804+
update.message !== null &&
805+
(update.message as { role?: unknown }).role === "assistant",
806+
);
807+
expect(assistantUpdates).toEqual([]);
808+
const transcriptLines = fs
809+
.readFileSync(mockState.transcriptPath, "utf-8")
810+
.split("\n")
811+
.filter(Boolean)
812+
.map((line) => JSON.parse(line) as Record<string, unknown>);
813+
const assistantEntries = transcriptLines.filter(
814+
(entry) =>
815+
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
816+
(entry as { role?: string }).role === "assistant",
817+
);
818+
expect(assistantEntries).toEqual([]);
759819
});
760820

761821
it("keeps visible text on non-agent TTS final media because no model transcript exists", async () => {

0 commit comments

Comments
 (0)