diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8da00be2a..1cb795f8468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts index c70bedde48e..23b09368d78 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts @@ -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"); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 91d02918cc1..8ea0f8d25f1 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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; } diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index fe26ee538f2..f68b4316e60 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -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); + 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); + 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 () => {