diff --git a/extensions/codex/src/app-server/context-compaction-activity.test.ts b/extensions/codex/src/app-server/context-compaction-activity.test.ts new file mode 100644 index 000000000000..49bb90d820e0 --- /dev/null +++ b/extensions/codex/src/app-server/context-compaction-activity.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { persistCodexContextCompactionActivity } from "./context-compaction-activity.js"; + +const appendMessage = vi.hoisted(() => vi.fn()); +const publishUpdate = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/session-transcript-runtime", () => ({ + appendSessionTranscriptMessageByIdentity: appendMessage, + publishSessionTranscriptUpdateByIdentity: publishUpdate, +})); + +beforeEach(() => { + appendMessage.mockReset(); + publishUpdate.mockReset(); +}); + +describe("persistCodexContextCompactionActivity", () => { + it("publishes one model-excluded activity and leaves replay deduplication to transcript identity", async () => { + appendMessage + .mockImplementationOnce(async (params: { message: unknown }) => ({ + appended: true, + message: params.message, + messageId: "activity-message", + })) + .mockResolvedValueOnce({ + appended: false, + message: {}, + messageId: "activity-message", + }); + const params = { + run: { + runId: "run-1", + workspaceDir: "/workspace", + sessionTarget: { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:dashboard:session-1", + storePath: "/state/openclaw-agent.sqlite", + }, + }, + threadId: "thread-1", + turnId: "turn-1", + itemId: "compact-1", + timestamp: 123, + } as Parameters[0]; + + await persistCodexContextCompactionActivity(params); + await persistCodexContextCompactionActivity(params); + + expect(appendMessage).toHaveBeenCalledTimes(2); + expect(appendMessage.mock.calls[0]?.[0]).toMatchObject({ + eventId: "codex-context-compaction:thread-1:turn-1:compact-1", + message: { + role: "custom", + customType: "openclaw.context-compaction", + content: "Context compacted", + display: true, + excludeFromContext: true, + idempotencyKey: "codex-context-compaction:thread-1:turn-1:compact-1", + }, + }); + expect(publishUpdate).toHaveBeenCalledOnce(); + expect(publishUpdate.mock.calls[0]?.[0]).toMatchObject({ + update: { + messageId: "activity-message", + runId: "run-1", + }, + }); + }); +}); diff --git a/packages/agent-core/src/harness/session/session-context.test.ts b/packages/agent-core/src/harness/session/session-context.test.ts index 412f32837eaf..176cddee30b0 100644 --- a/packages/agent-core/src/harness/session/session-context.test.ts +++ b/packages/agent-core/src/harness/session/session-context.test.ts @@ -133,6 +133,19 @@ function toolResultEntry( } describe("buildSessionContext", () => { + it("keeps display-only custom activity out of model input", () => { + const activity = { + role: "custom" as const, + customType: "openclaw.context-compaction", + content: "Context compacted", + display: true, + excludeFromContext: true, + timestamp: Date.parse(timestamp), + }; + + expect(convertToLlm([activity])).toEqual([]); + }); + it("keeps private shell executions in history without projecting them into context", () => { const hiddenEntry = bashEntry("hidden", "initial", "private shell output", true); const visibleEntry = bashEntry("visible", "hidden", "visible shell output", false); diff --git a/ui/src/pages/chat/chat-thread-grouping.ts b/ui/src/pages/chat/chat-thread-grouping.ts index d1e4ecfa5ce7..64236e2a5ba4 100644 --- a/ui/src/pages/chat/chat-thread-grouping.ts +++ b/ui/src/pages/chat/chat-thread-grouping.ts @@ -86,12 +86,18 @@ export function groupMessages(items: ChatItem[]): Array currentGroup?.role === "assistant" && isKeyedAssistantStreamFallbackMessage(currentGroup.messages[0]?.message) !== isKeyedAssistantStreamFallbackMessage(item.message); + const splitsRuntimeActivity = + role === "assistant" && + currentGroup?.role === "assistant" && + isContextCompactionActivity(currentGroup.messages[0]?.message) !== + isContextCompactionActivity(item.message); if ( !currentGroup || startsProjectedTurn || currentGroup.role !== role || splitsAssistantCommentary || + splitsRuntimeActivity || (shouldSplitBySender && (currentGroup.senderLabel !== senderLabel || senderIdentityKey(currentGroup.sender) !== senderIdentityKey(sender))) @@ -542,14 +548,15 @@ export function assistantGroupCanOwnActiveRunStatus(group: MessageGroup): boolea return ( group.role.toLowerCase() === "assistant" && !assistantGroupIsForwardedBoundary(group) && - !group.messages.every( - ({ message }) => - asRecord(asRecord(message)?.["__openclaw"])?.runtimeActivityKind === "context_compaction", - ) && + !group.messages.every(({ message }) => isContextCompactionActivity(message)) && groupHasVisibleReplyContent(group) ); } +function isContextCompactionActivity(message: unknown): boolean { + return asRecord(asRecord(message)?.["__openclaw"])?.runtimeActivityKind === "context_compaction"; +} + // History carries no final-vs-commentary marker (commentary exists only as // live stream segments), so the last assistant group with visible content // stands in for the final reply. Turns whose last content is commentary diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 8c7154e12a7f..93919ac98e0f 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -757,6 +757,35 @@ describe("collapseCompletedTurnWork", () => { expect(requireGroup(items[2]).role).toBe("assistant"); }); + it("keeps durable context compaction inside completed work instead of treating it as the reply", () => { + const items = collapsedItems({ + messages: [ + userMessage("do it", 1_000), + { + role: "custom", + customType: "openclaw.context-compaction", + content: "Context compacted", + display: true, + excludeFromContext: true, + timestamp: 2_000, + }, + assistantMessage("All done.", 3_000), + ], + }); + + expect(items.map((item) => item.kind)).toEqual(["group", "work-group", "group"]); + const work = requireWorkGroup(items[1]); + expect(work.groups).toHaveLength(1); + expect(work.groups[0]?.messages[0]?.message).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "Context compacted" }], + __openclaw: { runtimeActivityKind: "context_compaction" }, + }); + expect(requireGroup(items[2]).messages[0]?.message).toMatchObject({ + content: "All done.", + }); + }); + it.each([ { role: "assistant",