diff --git a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index af0881e88d3..3443ddc8783 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -139,14 +139,15 @@ def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None: messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages) self._fire_hooks(messages_to_summarize, preserved_messages, runtime) summary = self._create_summary(messages_to_summarize) - new_messages = self._build_new_messages(summary) + new_messages = self._build_hidden_summary_messages(summary) return { "messages": [ RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages, *preserved_messages, - ] + ], + "display_messages": self._visible_messages(messages_to_summarize), } async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None: @@ -165,16 +166,30 @@ async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages) self._fire_hooks(messages_to_summarize, preserved_messages, runtime) summary = await self._acreate_summary(messages_to_summarize) - new_messages = self._build_new_messages(summary) + new_messages = self._build_hidden_summary_messages(summary) return { "messages": [ RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages, *preserved_messages, - ] + ], + "display_messages": self._visible_messages(messages_to_summarize), } + def _build_hidden_summary_messages(self, summary: str) -> list[AnyMessage]: + new_messages = self._build_new_messages(summary) + for message in new_messages: + message.additional_kwargs = { + **getattr(message, "additional_kwargs", {}), + "hide_from_ui": True, + } + return new_messages + + @staticmethod + def _visible_messages(messages: list[AnyMessage]) -> list[AnyMessage]: + return [message for message in messages if getattr(message, "additional_kwargs", {}).get("hide_from_ui") is not True] + @override def _build_new_messages(self, summary: str) -> list[HumanMessage]: """Override the base implementation to let the human message with the special name 'summary'. diff --git a/backend/packages/harness/deerflow/agents/thread_state.py b/backend/packages/harness/deerflow/agents/thread_state.py index 2d87c3ee317..4fa1b318d9d 100644 --- a/backend/packages/harness/deerflow/agents/thread_state.py +++ b/backend/packages/harness/deerflow/agents/thread_state.py @@ -1,6 +1,7 @@ from typing import Annotated, NotRequired, TypedDict from langchain.agents import AgentState +from langchain_core.messages import AnyMessage class SandboxState(TypedDict): @@ -45,11 +46,31 @@ def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[s return {**existing, **new} +def merge_display_messages(existing: list[AnyMessage] | None, new: list[AnyMessage] | None) -> list[AnyMessage]: + """Reducer for UI-only messages archived before model-context summarization.""" + if existing is None: + existing = [] + if new is None: + return existing + + merged: list[AnyMessage] = [] + seen_ids: set[str] = set() + for message in [*existing, *new]: + message_id = getattr(message, "id", None) + if message_id: + if message_id in seen_ids: + continue + seen_ids.add(message_id) + merged.append(message) + return merged + + class ThreadState(AgentState): sandbox: NotRequired[SandboxState | None] thread_data: NotRequired[ThreadDataState | None] title: NotRequired[str | None] artifacts: Annotated[list[str], merge_artifacts] + display_messages: Annotated[list[AnyMessage], merge_display_messages] todos: NotRequired[list | None] uploaded_files: NotRequired[list[dict] | None] viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type} diff --git a/backend/tests/test_summarization_middleware.py b/backend/tests/test_summarization_middleware.py index 9cd4fc72580..55a316a2013 100644 --- a/backend/tests/test_summarization_middleware.py +++ b/backend/tests/test_summarization_middleware.py @@ -112,6 +112,8 @@ def test_before_summarization_hook_receives_messages_before_compression() -> Non assert captured[0].agent_name is None assert isinstance(result["messages"][0], RemoveMessage) assert result["messages"][1].content.startswith("Here is a summary") + assert result["messages"][1].additional_kwargs["hide_from_ui"] is True + assert [message.content for message in result["display_messages"]] == ["user-1", "assistant-1"] def test_dynamic_context_reminder_is_preserved_across_summarization() -> None: @@ -193,6 +195,19 @@ async def test_abefore_model_calls_hooks_same_as_sync() -> None: assert [message.content for message in captured[0].messages_to_summarize] == ["user-1", "assistant-1"] +@pytest.mark.anyio +async def test_abefore_model_returns_visible_history_after_summarization() -> None: + middleware = _middleware() + + result = await middleware.abefore_model({"messages": _messages()}, _runtime()) + + assert isinstance(result["messages"][0], RemoveMessage) + assert result["messages"][1].content.startswith("Here is a summary") + assert result["messages"][1].additional_kwargs["hide_from_ui"] is True + assert [message.content for message in result["display_messages"]] == ["user-1", "assistant-1"] + assert [message.content for message in result["messages"][2:]] == ["user-2", "assistant-2"] + + def test_memory_flush_hook_skips_when_memory_disabled(monkeypatch: pytest.MonkeyPatch) -> None: queue = MagicMock() monkeypatch.setattr("deerflow.agents.memory.summarization_hook.get_memory_config", lambda: MemoryConfig(enabled=False)) diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index ffbf3e3ad42..99820af52d6 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -175,7 +175,10 @@ export function MessageList({ const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const updateSubtask = useUpdateSubtask(); - const messages = thread.messages; + const messages = useMemo( + () => [...(thread.values.display_messages ?? []), ...thread.messages], + [thread.messages, thread.values.display_messages], + ); const groupedMessages = getMessageGroups(messages); const turnUsageMessagesByGroupIndex = getAssistantTurnUsageMessages(groupedMessages); diff --git a/frontend/src/core/threads/types.ts b/frontend/src/core/threads/types.ts index dafb073494b..4b9f2f13818 100644 --- a/frontend/src/core/threads/types.ts +++ b/frontend/src/core/threads/types.ts @@ -5,6 +5,7 @@ import type { Todo } from "../todos"; export interface AgentThreadState extends Record { title: string; messages: Message[]; + display_messages?: Message[]; artifacts: string[]; todos?: Todo[]; } diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index e608793df96..1fc878a1f88 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from "@playwright/test"; -import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api"; +import { + handleRunStream, + MOCK_THREAD_ID_2, + mockLangGraphAPI, +} from "./utils/mock-api"; test.describe("Chat workspace", () => { test.beforeEach(async ({ page }) => { @@ -49,6 +53,68 @@ test.describe("Chat workspace", () => { }); }); + test("shows archived messages after summarization without showing the summary", async ({ + page, + }) => { + await page.unrouteAll({ behavior: "ignoreErrors" }); + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID_2, + title: "LLM Wiki Report Outline", + values: { + display_messages: [ + { + type: "human", + id: "archived-human-1", + content: [ + { + type: "text", + text: "Generate a McKinsey-grade research report on LLM Wiki.", + }, + ], + }, + { + type: "ai", + id: "archived-ai-1", + content: "I will gather sources and build the report outline.", + }, + ], + messages: [ + { + type: "human", + id: "summary-hidden", + content: + "Here is a summary of the conversation to date:\n\nCore Task...", + additional_kwargs: { hide_from_ui: true }, + }, + { + type: "ai", + id: "current-ai-1", + content: "Continuing with the final report section.", + }, + ], + }, + }, + ], + }); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID_2}`); + + await expect( + page.getByText("Generate a McKinsey-grade research report on LLM Wiki."), + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByText("I will gather sources and build the report outline."), + ).toBeVisible(); + await expect( + page.getByText("Continuing with the final report section."), + ).toBeVisible(); + await expect( + page.getByText("Here is a summary of the conversation to date"), + ).toHaveCount(0); + }); + test("keeps attachments visible while upload submit is pending", async ({ page, }) => { diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index e2d515329e2..3b10fe7806e 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -6,6 +6,7 @@ * `handleRunStream` from here. */ +import type { Message } from "@langchain/langgraph-sdk"; import type { Page, Route } from "@playwright/test"; // --------------------------------------------------------------------------- @@ -25,6 +26,11 @@ export type MockThread = { title?: string; updated_at?: string; agent_name?: string; + values?: { + title?: string; + messages?: Message[]; + display_messages?: Message[]; + }; }; export type MockAgent = { @@ -59,7 +65,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { updated_at: t.updated_at ?? "2025-01-01T00:00:00Z", metadata: t.agent_name ? { agent_name: t.agent_name } : {}, status: "idle", - values: { title: t.title ?? "Untitled" }, + values: { title: t.title ?? t.values?.title ?? "Untitled" }, })); return route.fulfill({ status: 200, @@ -112,8 +118,12 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { body: JSON.stringify([ { values: { - title: matchingThread.title ?? "Untitled", - messages: [ + title: + matchingThread.title ?? + matchingThread.values?.title ?? + "Untitled", + display_messages: matchingThread.values?.display_messages, + messages: matchingThread.values?.messages ?? [ { type: "human", id: `msg-human-${matchingThread.thread_id}`, @@ -122,7 +132,9 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { { type: "ai", id: `msg-ai-${matchingThread.thread_id}`, - content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`, + content: `Response in thread ${ + matchingThread.title ?? matchingThread.thread_id + }`, }, ], }, @@ -153,9 +165,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { contentType: "application/json", body: JSON.stringify({ values: { - title: matchingThread?.title ?? "Untitled", + title: + matchingThread?.title ?? + matchingThread?.values?.title ?? + "Untitled", + display_messages: matchingThread?.values?.display_messages, messages: matchingThread - ? [ + ? (matchingThread.values?.messages ?? [ { type: "human", id: `msg-human-${matchingThread.thread_id}`, @@ -164,9 +180,11 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { { type: "ai", id: `msg-ai-${matchingThread.thread_id}`, - content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`, + content: `Response in thread ${ + matchingThread.title ?? matchingThread.thread_id + }`, }, - ] + ]) : [], }, next: [],