mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix: prevent persisted turn replay
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user