mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-14 04:33:45 +00:00
* feat(persistence): add SQLAlchemy 2.0 async ORM scaffold
Introduce a unified database configuration (DatabaseConfig) that
controls both the LangGraph checkpointer and the DeerFlow application
persistence layer from a single `database:` config section.
New modules:
- deerflow.config.database_config — Pydantic config with memory/sqlite/postgres backends
- deerflow.persistence — async engine lifecycle, DeclarativeBase with to_dict mixin, Alembic skeleton
- deerflow.runtime.runs.store — RunStore ABC + MemoryRunStore implementation
Gateway integration initializes/tears down the persistence engine in
the existing langgraph_runtime() context manager. Legacy checkpointer
config is preserved for backward compatibility.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(persistence): add RunEventStore ABC + MemoryRunEventStore
Phase 2-A prerequisite for event storage: adds the unified run event
stream interface (RunEventStore) with an in-memory implementation,
RunEventsConfig, gateway integration, and comprehensive tests (27 cases).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(persistence): add ORM models, repositories, DB/JSONL event stores, RunJournal, and API endpoints
Phase 2-B: run persistence + event storage + token tracking.
- ORM models: RunRow (with token fields), ThreadMetaRow, RunEventRow
- RunRepository implements RunStore ABC via SQLAlchemy ORM
- ThreadMetaRepository with owner access control
- DbRunEventStore with trace content truncation and cursor pagination
- JsonlRunEventStore with per-run files and seq recovery from disk
- RunJournal (BaseCallbackHandler) captures LLM/tool/lifecycle events,
accumulates token usage by caller type, buffers and flushes to store
- RunManager now accepts optional RunStore for persistent backing
- Worker creates RunJournal, writes human_message, injects callbacks
- Gateway deps use factory functions (RunRepository when DB available)
- New endpoints: messages, run messages, run events, token-usage
- ThreadCreateRequest gains assistant_id field
- 92 tests pass (33 new), zero regressions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(persistence): add user feedback + follow-up run association
Phase 2-C: feedback and follow-up tracking.
- FeedbackRow ORM model (rating +1/-1, optional message_id, comment)
- FeedbackRepository with CRUD, list_by_run/thread, aggregate stats
- Feedback API endpoints: create, list, stats, delete
- follow_up_to_run_id in RunCreateRequest (explicit or auto-detected
from latest successful run on the thread)
- Worker writes follow_up_to_run_id into human_message event metadata
- Gateway deps: feedback_repo factory + getter
- 17 new tests (14 FeedbackRepository + 3 follow-up association)
- 109 total tests pass, zero regressions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test+config: comprehensive Phase 2 test coverage + deprecate checkpointer config
- config.example.yaml: deprecate standalone checkpointer section, activate
unified database:sqlite as default (drives both checkpointer + app data)
- New: test_thread_meta_repo.py (14 tests) — full ThreadMetaRepository coverage
including check_access owner logic, list_by_owner pagination
- Extended test_run_repository.py (+4 tests) — completion preserves fields,
list ordering desc, limit, owner_none returns all
- Extended test_run_journal.py (+8 tests) — on_chain_error, track_tokens=false,
middleware no ai_message, unknown caller tokens, convenience fields,
tool_error, non-summarization custom event
- Extended test_run_event_store.py (+7 tests) — DB batch seq continuity,
make_run_event_store factory (memory/db/jsonl/fallback/unknown)
- Extended test_phase2b_integration.py (+4 tests) — create_or_reject persists,
follow-up metadata, summarization in history, full DB-backed lifecycle
- Fixed DB integration test to use proper fake objects (not MagicMock)
for JSON-serializable metadata
- 157 total Phase 2 tests pass, zero regressions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* config: move default sqlite_dir to .deer-flow/data
Keep SQLite databases alongside other DeerFlow-managed data
(threads, memory) under the .deer-flow/ directory instead of a
top-level ./data folder.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(persistence): remove UTFJSON, use engine-level json_serializer + datetime.now()
- Replace custom UTFJSON type with standard sqlalchemy.JSON in all ORM
models. Add json_serializer=json.dumps(ensure_ascii=False) to all
create_async_engine calls so non-ASCII text (Chinese etc.) is stored
as-is in both SQLite and Postgres.
- Change ORM datetime defaults from datetime.now(UTC) to datetime.now(),
remove UTC imports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(gateway): simplify deps.py with getter factory + inline repos
- Replace 6 identical getter functions with _require() factory.
- Inline 3 _make_*_repo() factories into langgraph_runtime(), call
get_session_factory() once instead of 3 times.
- Add thread_meta upsert in start_run (services.py).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(docker): add UV_EXTRAS build arg for optional dependencies
Support installing optional dependency groups (e.g. postgres) at
Docker build time via UV_EXTRAS build arg:
UV_EXTRAS=postgres docker compose build
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(journal): fix flush, token tracking, and consolidate tests
RunJournal fixes:
- _flush_sync: retain events in buffer when no event loop instead of
dropping them; worker's finally block flushes via async flush().
- on_llm_end: add tool_calls filter and caller=="lead_agent" guard for
ai_message events; mark message IDs for dedup with record_llm_usage.
- worker.py: persist completion data (tokens, message count) to RunStore
in finally block.
Model factory:
- Auto-inject stream_usage=True for BaseChatOpenAI subclasses with
custom api_base, so usage_metadata is populated in streaming responses.
Test consolidation:
- Delete test_phase2b_integration.py (redundant with existing tests).
- Move DB-backed lifecycle test into test_run_journal.py.
- Add tests for stream_usage injection in test_model_factory.py.
- Clean up executor/task_tool dead journal references.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(events): widen content type to str|dict in all store backends
Allow event content to be a dict (for structured OpenAI-format messages)
in addition to plain strings. Dict values are JSON-serialized for the DB
backend and deserialized on read; memory and JSONL backends handle dicts
natively. Trace truncation now serializes dicts to JSON before measuring.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(events): use metadata flag instead of heuristic for dict content detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(converters): add LangChain-to-OpenAI message format converters
Pure functions langchain_to_openai_message, langchain_to_openai_completion,
langchain_messages_to_openai, and _infer_finish_reason for converting
LangChain BaseMessage objects to OpenAI Chat Completions format, used by
RunJournal for event storage. 15 unit tests added.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(converters): handle empty list content as null, clean up test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(events): human_message content uses OpenAI user message format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(events): ai_message uses OpenAI format, add ai_tool_call message event
- ai_message content now uses {"role": "assistant", "content": "..."} format
- New ai_tool_call message event emitted when lead_agent LLM responds with tool_calls
- ai_tool_call uses langchain_to_openai_message converter for consistent format
- Both events include finish_reason in metadata ("stop" or "tool_calls")
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(events): add tool_result message event with OpenAI tool message format
Cache tool_call_id from on_tool_start keyed by run_id as fallback for on_tool_end,
then emit a tool_result message event (role=tool, tool_call_id, content) after each
successful tool completion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(events): summary content uses OpenAI system message format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(events): replace llm_start/llm_end with llm_request/llm_response in OpenAI format
Add on_chat_model_start to capture structured prompt messages as llm_request events.
Replace llm_end trace events with llm_response using OpenAI Chat Completions format.
Track llm_call_index to pair request/response events.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(events): add record_middleware method for middleware trace events
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(events): add full run sequence integration test for OpenAI content format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(events): align message events with checkpoint format and add middleware tag injection
- Message events (ai_message, ai_tool_call, tool_result, human_message) now use
BaseMessage.model_dump() format, matching LangGraph checkpoint values.messages
- on_tool_end extracts tool_call_id/name/status from ToolMessage objects
- on_tool_error now emits tool_result message events with error status
- record_middleware uses middleware:{tag} event_type and middleware category
- Summarization custom events use middleware:summarize category
- TitleMiddleware injects middleware:title tag via get_config() inheritance
- SummarizationMiddleware model bound with middleware:summarize tag
- Worker writes human_message using HumanMessage.model_dump()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(threads): switch search endpoint to threads_meta table and sync title
- POST /api/threads/search now queries threads_meta table directly,
removing the two-phase Store + Checkpointer scan approach
- Add ThreadMetaRepository.search() with metadata/status filters
- Add ThreadMetaRepository.update_display_name() for title sync
- Worker syncs checkpoint title to threads_meta.display_name on run completion
- Map display_name to values.title in search response for API compatibility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(threads): history endpoint reads messages from event store
- POST /api/threads/{thread_id}/history now combines two data sources:
checkpointer for checkpoint_id, metadata, title, thread_data;
event store for messages (complete history, not truncated by summarization)
- Strip internal LangGraph metadata keys from response
- Remove full channel_values serialization in favor of selective fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove duplicate optional-dependencies header in pyproject.toml
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(middleware): pass tagged config to TitleMiddleware ainvoke call
Without the config, the middleware:title tag was not injected,
causing the LLM response to be recorded as a lead_agent ai_message
in run_events.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve merge conflict in .env.example
Keep both DATABASE_URL (from persistence-scaffold) and WECOM
credentials (from main) after the merge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(persistence): address review feedback on PR #1851
- Fix naive datetime.now() → datetime.now(UTC) in all ORM models
- Fix seq race condition in DbRunEventStore.put() with FOR UPDATE
and UNIQUE(thread_id, seq) constraint
- Encapsulate _store access in RunManager.update_run_completion()
- Deduplicate _store.put() logic in RunManager via _persist_to_store()
- Add update_run_completion to RunStore ABC + MemoryRunStore
- Wire follow_up_to_run_id through the full create path
- Add error recovery to RunJournal._flush_sync() lost-event scenario
- Add migration note for search_threads breaking change
- Fix test_checkpointer_none_fix mock to set database=None
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: update uv.lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(persistence): address 22 review comments from CodeQL, Copilot, and Code Quality
Bug fixes:
- Sanitize log params to prevent log injection (CodeQL)
- Reset threads_meta.status to idle/error when run completes
- Attach messages only to latest checkpoint in /history response
- Write threads_meta on POST /threads so new threads appear in search
Lint fixes:
- Remove unused imports (journal.py, migrations/env.py, test_converters.py)
- Convert lambda to named function (engine.py, Ruff E731)
- Remove unused logger definitions in repos (Ruff F841)
- Add logging to JSONL decode errors and empty except blocks
- Separate assert side-effects in tests (CodeQL)
- Remove unused local variables in tests (Ruff F841)
- Fix max_trace_content truncation to use byte length, not char length
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: apply ruff format to persistence and runtime files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Potential fix for pull request finding 'Statement has no effect'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* refactor(runtime): introduce RunContext to reduce run_agent parameter bloat
Extract checkpointer, store, event_store, run_events_config, thread_meta_repo,
and follow_up_to_run_id into a frozen RunContext dataclass. Add get_run_context()
in deps.py to build the base context from app.state singletons. start_run() uses
dataclasses.replace() to enrich per-run fields before passing ctx to run_agent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(gateway): move sanitize_log_param to app/gateway/utils.py
Extract the log-injection sanitizer from routers/threads.py into a shared
utils module and rename to sanitize_log_param (public API). Eliminates the
reverse service → router import in services.py.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: use SQL aggregation for feedback stats and thread token usage
Replace Python-side counting in FeedbackRepository.aggregate_by_run with
a single SELECT COUNT/SUM query. Add RunStore.aggregate_tokens_by_thread
abstract method with SQL GROUP BY implementation in RunRepository and
Python fallback in MemoryRunStore. Simplify the thread_token_usage
endpoint to delegate to the new method, eliminating the limit=10000
truncation risk.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: annotate DbRunEventStore.put() as low-frequency path
Add docstring clarifying that put() opens a per-call transaction with
FOR UPDATE and should only be used for infrequent writes (currently
just the initial human_message event). High-throughput callers should
use put_batch() instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(threads): fall back to Store search when ThreadMetaRepository is unavailable
When database.backend=memory (default) or no SQL session factory is
configured, search_threads now queries the LangGraph Store instead of
returning 503. Returns empty list if neither Store nor repo is available.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(persistence): introduce ThreadMetaStore ABC for backend-agnostic thread metadata
Add ThreadMetaStore abstract base class with create/get/search/update/delete
interface. ThreadMetaRepository (SQL) now inherits from it. New
MemoryThreadMetaStore wraps LangGraph BaseStore for memory-mode deployments.
deps.py now always provides a non-None thread_meta_repo, eliminating all
`if thread_meta_repo is not None` guards in services.py, worker.py, and
routers/threads.py. search_threads no longer needs a Store fallback branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(history): read messages from checkpointer instead of RunEventStore
The /history endpoint now reads messages directly from the
checkpointer's channel_values (the authoritative source) instead of
querying RunEventStore.list_messages(). The RunEventStore API is
preserved for other consumers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(persistence): address new Copilot review comments
- feedback.py: validate thread_id/run_id before deleting feedback
- jsonl.py: add path traversal protection with ID validation
- run_repo.py: parse `before` to datetime for PostgreSQL compat
- thread_meta_repo.py: fix pagination when metadata filter is active
- database_config.py: use resolve_path for sqlite_dir consistency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Implement skill self-evolution and skill_manage flow (#1874)
* chore: ignore .worktrees directory
* Add skill_manage self-evolution flow
* Fix CI regressions for skill_manage
* Address PR review feedback for skill evolution
* fix(skill-evolution): preserve history on delete
* fix(skill-evolution): tighten scanner fallbacks
* docs: add skill_manage e2e evidence screenshot
* fix(skill-manage): avoid blocking fs ops in session runtime
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(config): resolve sqlite_dir relative to CWD, not Paths.base_dir
resolve_path() resolves relative to Paths.base_dir (.deer-flow),
which double-nested the path to .deer-flow/.deer-flow/data/app.db.
Use Path.resolve() (CWD-relative) instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Feature/feishu receive file (#1608)
* feat(feishu): add channel file materialization hook for inbound messages
- Introduce Channel.receive_file(msg, thread_id) as a base method for file materialization; default is no-op.
- Implement FeishuChannel.receive_file to download files/images from Feishu messages, save to sandbox, and inject virtual paths into msg.text.
- Update ChannelManager to call receive_file for any channel if msg.files is present, enabling downstream model access to user-uploaded files.
- No impact on Slack/Telegram or other channels (they inherit the default no-op).
* style(backend): format code with ruff for lint compliance
- Auto-formatted packages/harness/deerflow/agents/factory.py and tests/test_create_deerflow_agent.py using `ruff format`
- Ensured both files conform to project linting standards
- Fixes CI lint check failures caused by code style issues
* fix(feishu): handle file write operation asynchronously to prevent blocking
* fix(feishu): rename GetMessageResourceRequest to _GetMessageResourceRequest and remove redundant code
* test(feishu): add tests for receive_file method and placeholder replacement
* fix(manager): remove unnecessary type casting for channel retrieval
* fix(feishu): update logging messages to reflect resource handling instead of image
* fix(feishu): sanitize filename by replacing invalid characters in file uploads
* fix(feishu): improve filename sanitization and reorder image key handling in message processing
* fix(feishu): add thread lock to prevent filename conflicts during file downloads
* fix(test): correct bad merge in test_feishu_parser.py
* chore: run ruff and apply formatting cleanup
fix(feishu): preserve rich-text attachment order and improve fallback filename handling
* fix(docker): restore gateway env vars and fix langgraph empty arg issue (#1915)
Two production docker-compose.yaml bugs prevent `make up` from working:
1. Gateway missing DEER_FLOW_CONFIG_PATH and DEER_FLOW_EXTENSIONS_CONFIG_PATH
environment overrides. Added in fb2d99f (#1836) but accidentally reverted
by ca2fb95 (#1847). Without them, gateway reads host paths from .env via
env_file, causing FileNotFoundError inside the container.
2. Langgraph command fails when LANGGRAPH_ALLOW_BLOCKING is unset (default).
Empty $${allow_blocking} inserts a bare space between flags, causing
' --no-reload' to be parsed as unexpected extra argument. Fix by building
args string first and conditionally appending --allow-blocking.
Co-authored-by: cooper <cooperfu@tencent.com>
* fix(frontend): resolve invalid HTML nesting and tabnabbing vulnerabilities (#1904)
* fix(frontend): resolve invalid HTML nesting and tabnabbing vulnerabilities
Fix `<button>` inside `<a>` invalid HTML in artifact components and add
missing `noopener,noreferrer` to `window.open` calls to prevent reverse
tabnabbing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(frontend): address Copilot review on tabnabbing and double-tab-open
Remove redundant parent onClick on web_fetch ChainOfThoughtStep to
prevent opening two tabs on link click, and explicitly null out
window.opener after window.open() for defensive tabnabbing hardening.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(persistence): organize entities into per-entity directories
Restructure the persistence layer from horizontal "models/ + repositories/"
split into vertical entity-aligned directories. Each entity (thread_meta,
run, feedback) now owns its ORM model, abstract interface (where applicable),
and concrete implementations under a single directory with an aggregating
__init__.py for one-line imports.
Layout:
persistence/thread_meta/{base,model,sql,memory}.py
persistence/run/{model,sql}.py
persistence/feedback/{model,sql}.py
models/__init__.py is kept as a facade so Alembic autogenerate continues to
discover all ORM tables via Base.metadata. RunEventRow remains under
models/run_event.py because its storage implementation lives in
runtime/events/store/db.py and has no matching repository directory.
The repositories/ directory is removed entirely. All call sites in
gateway/deps.py and tests are updated to import from the new entity
packages, e.g.:
from deerflow.persistence.thread_meta import ThreadMetaRepository
from deerflow.persistence.run import RunRepository
from deerflow.persistence.feedback import FeedbackRepository
Full test suite passes (1690 passed, 14 skipped).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): sync thread rename and delete through ThreadMetaStore
The POST /threads/{id}/state endpoint previously synced title changes
only to the LangGraph Store via _store_upsert. In sqlite mode the search
endpoint reads from the ThreadMetaRepository SQL table, so renames never
appeared in /threads/search until the next agent run completed (worker.py
syncs title from checkpoint to thread_meta in its finally block).
Likewise the DELETE /threads/{id} endpoint cleaned up the filesystem,
Store, and checkpointer but left the threads_meta row orphaned in sqlite,
so deleted threads kept appearing in /threads/search.
Fix both endpoints by routing through the ThreadMetaStore abstraction
which already has the correct sqlite/memory implementations wired up by
deps.py. The rename path now calls update_display_name() and the delete
path calls delete() — both work uniformly across backends.
Verified end-to-end with curl in gateway mode against sqlite backend.
Existing test suite (1690 passed) and focused router/repo tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(gateway): route all thread metadata access through ThreadMetaStore
Following the rename/delete bug fix in PR1, migrate the remaining direct
LangGraph Store reads/writes in the threads router and services to the
ThreadMetaStore abstraction so that the sqlite and memory backends behave
identically and the legacy dual-write paths can be removed.
Migrated endpoints (threads.py):
- create_thread: idempotency check + write now use thread_meta_repo.get/create
instead of dual-writing the LangGraph Store and the SQL row.
- get_thread: reads from thread_meta_repo.get; the checkpoint-only fallback
for legacy threads is preserved.
- patch_thread: replaced _store_get/_store_put with thread_meta_repo.update_metadata.
- delete_thread_data: dropped the legacy store.adelete; thread_meta_repo.delete
already covers it.
Removed dead code (services.py):
- _upsert_thread_in_store — redundant with the immediately following
thread_meta_repo.create() call.
- _sync_thread_title_after_run — worker.py's finally block already syncs
the title via thread_meta_repo.update_display_name() after each run.
Removed dead code (threads.py):
- _store_get / _store_put / _store_upsert helpers (no remaining callers).
- THREADS_NS constant.
- get_store import (router no longer touches the LangGraph Store directly).
New abstract method:
- ThreadMetaStore.update_metadata(thread_id, metadata) merges metadata into
the thread's metadata field. Implemented in both ThreadMetaRepository (SQL,
read-modify-write inside one session) and MemoryThreadMetaStore. Three new
unit tests cover merge / empty / nonexistent behaviour.
Net change: -134 lines. Full test suite: 1693 passed, 14 skipped.
Verified end-to-end with curl in gateway mode against sqlite backend
(create / patch / get / rename / search / delete).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: JilongSun <965640067@qq.com>
Co-authored-by: jie <49781832+stan-fu@users.noreply.github.com>
Co-authored-by: cooper <cooperfu@tencent.com>
Co-authored-by: yangzheli <43645580+yangzheli@users.noreply.github.com>
461 lines
16 KiB
Python
461 lines
16 KiB
Python
"""Tests for channel file attachment support (ResolvedAttachment, resolution, send_file)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from app.channels.base import Channel
|
|
from app.channels.message_bus import MessageBus, OutboundMessage, ResolvedAttachment
|
|
|
|
|
|
def _run(coro):
|
|
"""Run an async coroutine synchronously."""
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ResolvedAttachment tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolvedAttachment:
|
|
def test_basic_construction(self, tmp_path):
|
|
f = tmp_path / "test.pdf"
|
|
f.write_bytes(b"PDF content")
|
|
|
|
att = ResolvedAttachment(
|
|
virtual_path="/mnt/user-data/outputs/test.pdf",
|
|
actual_path=f,
|
|
filename="test.pdf",
|
|
mime_type="application/pdf",
|
|
size=11,
|
|
is_image=False,
|
|
)
|
|
assert att.filename == "test.pdf"
|
|
assert att.is_image is False
|
|
assert att.size == 11
|
|
|
|
def test_image_detection(self, tmp_path):
|
|
f = tmp_path / "photo.png"
|
|
f.write_bytes(b"\x89PNG")
|
|
|
|
att = ResolvedAttachment(
|
|
virtual_path="/mnt/user-data/outputs/photo.png",
|
|
actual_path=f,
|
|
filename="photo.png",
|
|
mime_type="image/png",
|
|
size=4,
|
|
is_image=True,
|
|
)
|
|
assert att.is_image is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OutboundMessage.attachments field tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOutboundMessageAttachments:
|
|
def test_default_empty_attachments(self):
|
|
msg = OutboundMessage(
|
|
channel_name="test",
|
|
chat_id="c1",
|
|
thread_id="t1",
|
|
text="hello",
|
|
)
|
|
assert msg.attachments == []
|
|
|
|
def test_attachments_populated(self, tmp_path):
|
|
f = tmp_path / "file.txt"
|
|
f.write_text("content")
|
|
|
|
att = ResolvedAttachment(
|
|
virtual_path="/mnt/user-data/outputs/file.txt",
|
|
actual_path=f,
|
|
filename="file.txt",
|
|
mime_type="text/plain",
|
|
size=7,
|
|
is_image=False,
|
|
)
|
|
msg = OutboundMessage(
|
|
channel_name="test",
|
|
chat_id="c1",
|
|
thread_id="t1",
|
|
text="hello",
|
|
attachments=[att],
|
|
)
|
|
assert len(msg.attachments) == 1
|
|
assert msg.attachments[0].filename == "file.txt"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_attachments tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveAttachments:
|
|
def test_resolves_existing_file(self, tmp_path):
|
|
"""Successfully resolves a virtual path to an existing file."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
# Create the directory structure: threads/{thread_id}/user-data/outputs/
|
|
thread_id = "test-thread-123"
|
|
outputs_dir = tmp_path / "threads" / thread_id / "user-data" / "outputs"
|
|
outputs_dir.mkdir(parents=True)
|
|
test_file = outputs_dir / "report.pdf"
|
|
test_file.write_bytes(b"%PDF-1.4 fake content")
|
|
|
|
mock_paths = MagicMock()
|
|
mock_paths.resolve_virtual_path.return_value = test_file
|
|
mock_paths.sandbox_outputs_dir.return_value = outputs_dir
|
|
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments(thread_id, ["/mnt/user-data/outputs/report.pdf"])
|
|
|
|
assert len(result) == 1
|
|
assert result[0].filename == "report.pdf"
|
|
assert result[0].mime_type == "application/pdf"
|
|
assert result[0].is_image is False
|
|
assert result[0].size == len(b"%PDF-1.4 fake content")
|
|
|
|
def test_resolves_image_file(self, tmp_path):
|
|
"""Images are detected by MIME type."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
thread_id = "test-thread"
|
|
outputs_dir = tmp_path / "threads" / thread_id / "user-data" / "outputs"
|
|
outputs_dir.mkdir(parents=True)
|
|
img = outputs_dir / "chart.png"
|
|
img.write_bytes(b"\x89PNG fake image")
|
|
|
|
mock_paths = MagicMock()
|
|
mock_paths.resolve_virtual_path.return_value = img
|
|
mock_paths.sandbox_outputs_dir.return_value = outputs_dir
|
|
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments(thread_id, ["/mnt/user-data/outputs/chart.png"])
|
|
|
|
assert len(result) == 1
|
|
assert result[0].is_image is True
|
|
assert result[0].mime_type == "image/png"
|
|
|
|
def test_skips_missing_file(self, tmp_path):
|
|
"""Missing files are skipped with a warning."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
outputs_dir = tmp_path / "outputs"
|
|
outputs_dir.mkdir()
|
|
|
|
mock_paths = MagicMock()
|
|
mock_paths.resolve_virtual_path.return_value = outputs_dir / "nonexistent.txt"
|
|
mock_paths.sandbox_outputs_dir.return_value = outputs_dir
|
|
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments("t1", ["/mnt/user-data/outputs/nonexistent.txt"])
|
|
|
|
assert result == []
|
|
|
|
def test_skips_invalid_path(self):
|
|
"""Invalid paths (ValueError from resolve) are skipped."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
mock_paths = MagicMock()
|
|
mock_paths.resolve_virtual_path.side_effect = ValueError("bad path")
|
|
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments("t1", ["/invalid/path"])
|
|
|
|
assert result == []
|
|
|
|
def test_rejects_uploads_path(self):
|
|
"""Paths under /mnt/user-data/uploads/ are rejected (security)."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
mock_paths = MagicMock()
|
|
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments("t1", ["/mnt/user-data/uploads/secret.pdf"])
|
|
|
|
assert result == []
|
|
mock_paths.resolve_virtual_path.assert_not_called()
|
|
|
|
def test_rejects_workspace_path(self):
|
|
"""Paths under /mnt/user-data/workspace/ are rejected (security)."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
mock_paths = MagicMock()
|
|
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments("t1", ["/mnt/user-data/workspace/config.py"])
|
|
|
|
assert result == []
|
|
mock_paths.resolve_virtual_path.assert_not_called()
|
|
|
|
def test_rejects_path_traversal_escape(self, tmp_path):
|
|
"""Paths that escape the outputs directory after resolution are rejected."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
thread_id = "t1"
|
|
outputs_dir = tmp_path / "threads" / thread_id / "user-data" / "outputs"
|
|
outputs_dir.mkdir(parents=True)
|
|
# Simulate a resolved path that escapes outside the outputs directory
|
|
escaped_file = tmp_path / "threads" / thread_id / "user-data" / "uploads" / "stolen.txt"
|
|
escaped_file.parent.mkdir(parents=True, exist_ok=True)
|
|
escaped_file.write_text("sensitive")
|
|
|
|
mock_paths = MagicMock()
|
|
mock_paths.resolve_virtual_path.return_value = escaped_file
|
|
mock_paths.sandbox_outputs_dir.return_value = outputs_dir
|
|
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments(thread_id, ["/mnt/user-data/outputs/../uploads/stolen.txt"])
|
|
|
|
assert result == []
|
|
|
|
def test_multiple_artifacts_partial_resolution(self, tmp_path):
|
|
"""Mixed valid/invalid artifacts: only valid ones are returned."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
thread_id = "t1"
|
|
outputs_dir = tmp_path / "outputs"
|
|
outputs_dir.mkdir()
|
|
good_file = outputs_dir / "data.csv"
|
|
good_file.write_text("a,b,c")
|
|
|
|
mock_paths = MagicMock()
|
|
mock_paths.sandbox_outputs_dir.return_value = outputs_dir
|
|
|
|
def resolve_side_effect(tid, vpath):
|
|
if "data.csv" in vpath:
|
|
return good_file
|
|
return tmp_path / "missing.txt"
|
|
|
|
mock_paths.resolve_virtual_path.side_effect = resolve_side_effect
|
|
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments(
|
|
thread_id,
|
|
["/mnt/user-data/outputs/data.csv", "/mnt/user-data/outputs/missing.txt"],
|
|
)
|
|
|
|
assert len(result) == 1
|
|
assert result[0].filename == "data.csv"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Channel base class _on_outbound with attachments
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _DummyChannel(Channel):
|
|
"""Concrete channel for testing the base class behavior."""
|
|
|
|
def __init__(self, bus):
|
|
super().__init__(name="dummy", bus=bus, config={})
|
|
self.sent_messages: list[OutboundMessage] = []
|
|
self.sent_files: list[tuple[OutboundMessage, ResolvedAttachment]] = []
|
|
|
|
async def start(self):
|
|
pass
|
|
|
|
async def stop(self):
|
|
pass
|
|
|
|
async def send(self, msg: OutboundMessage) -> None:
|
|
self.sent_messages.append(msg)
|
|
|
|
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
|
|
self.sent_files.append((msg, attachment))
|
|
return True
|
|
|
|
|
|
class TestBaseChannelOnOutbound:
|
|
def test_default_receive_file_returns_original_message(self):
|
|
"""The base Channel.receive_file returns the original message unchanged."""
|
|
|
|
class MinimalChannel(Channel):
|
|
async def start(self):
|
|
pass
|
|
|
|
async def stop(self):
|
|
pass
|
|
|
|
async def send(self, msg):
|
|
pass
|
|
|
|
from app.channels.message_bus import InboundMessage
|
|
|
|
bus = MessageBus()
|
|
ch = MinimalChannel(name="minimal", bus=bus, config={})
|
|
msg = InboundMessage(channel_name="minimal", chat_id="c1", user_id="u1", text="hello", files=[{"file_key": "k1"}])
|
|
|
|
result = _run(ch.receive_file(msg, "thread-1"))
|
|
|
|
assert result is msg
|
|
assert result.text == "hello"
|
|
assert result.files == [{"file_key": "k1"}]
|
|
|
|
def test_send_file_called_for_each_attachment(self, tmp_path):
|
|
"""_on_outbound sends text first, then uploads each attachment."""
|
|
bus = MessageBus()
|
|
ch = _DummyChannel(bus)
|
|
|
|
f1 = tmp_path / "a.txt"
|
|
f1.write_text("aaa")
|
|
f2 = tmp_path / "b.png"
|
|
f2.write_bytes(b"\x89PNG")
|
|
|
|
att1 = ResolvedAttachment("/mnt/user-data/outputs/a.txt", f1, "a.txt", "text/plain", 3, False)
|
|
att2 = ResolvedAttachment("/mnt/user-data/outputs/b.png", f2, "b.png", "image/png", 4, True)
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dummy",
|
|
chat_id="c1",
|
|
thread_id="t1",
|
|
text="Here are your files",
|
|
attachments=[att1, att2],
|
|
)
|
|
|
|
_run(ch._on_outbound(msg))
|
|
|
|
assert len(ch.sent_messages) == 1
|
|
assert len(ch.sent_files) == 2
|
|
assert ch.sent_files[0][1].filename == "a.txt"
|
|
assert ch.sent_files[1][1].filename == "b.png"
|
|
|
|
def test_no_attachments_no_send_file(self):
|
|
"""When there are no attachments, send_file is not called."""
|
|
bus = MessageBus()
|
|
ch = _DummyChannel(bus)
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dummy",
|
|
chat_id="c1",
|
|
thread_id="t1",
|
|
text="No files here",
|
|
)
|
|
|
|
_run(ch._on_outbound(msg))
|
|
|
|
assert len(ch.sent_messages) == 1
|
|
assert len(ch.sent_files) == 0
|
|
|
|
def test_send_file_failure_does_not_block_others(self, tmp_path):
|
|
"""If one attachment upload fails, remaining attachments still get sent."""
|
|
bus = MessageBus()
|
|
ch = _DummyChannel(bus)
|
|
|
|
# Override send_file to fail on first call, succeed on second
|
|
call_count = 0
|
|
original_send_file = ch.send_file
|
|
|
|
async def flaky_send_file(msg, att):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise RuntimeError("upload failed")
|
|
return await original_send_file(msg, att)
|
|
|
|
ch.send_file = flaky_send_file # type: ignore
|
|
|
|
f1 = tmp_path / "fail.txt"
|
|
f1.write_text("x")
|
|
f2 = tmp_path / "ok.txt"
|
|
f2.write_text("y")
|
|
|
|
att1 = ResolvedAttachment("/mnt/user-data/outputs/fail.txt", f1, "fail.txt", "text/plain", 1, False)
|
|
att2 = ResolvedAttachment("/mnt/user-data/outputs/ok.txt", f2, "ok.txt", "text/plain", 1, False)
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dummy",
|
|
chat_id="c1",
|
|
thread_id="t1",
|
|
text="files",
|
|
attachments=[att1, att2],
|
|
)
|
|
|
|
_run(ch._on_outbound(msg))
|
|
|
|
# First upload failed, second succeeded
|
|
assert len(ch.sent_files) == 1
|
|
assert ch.sent_files[0][1].filename == "ok.txt"
|
|
|
|
def test_send_raises_skips_file_uploads(self, tmp_path):
|
|
"""When send() raises, file uploads are skipped entirely."""
|
|
bus = MessageBus()
|
|
ch = _DummyChannel(bus)
|
|
|
|
async def failing_send(msg):
|
|
raise RuntimeError("network error")
|
|
|
|
ch.send = failing_send # type: ignore
|
|
|
|
f = tmp_path / "a.pdf"
|
|
f.write_bytes(b"%PDF")
|
|
att = ResolvedAttachment("/mnt/user-data/outputs/a.pdf", f, "a.pdf", "application/pdf", 4, False)
|
|
msg = OutboundMessage(
|
|
channel_name="dummy",
|
|
chat_id="c1",
|
|
thread_id="t1",
|
|
text="Here is the file",
|
|
attachments=[att],
|
|
)
|
|
|
|
_run(ch._on_outbound(msg))
|
|
|
|
# send() raised, so send_file should never be called
|
|
assert len(ch.sent_files) == 0
|
|
|
|
def test_default_send_file_returns_false(self):
|
|
"""The base Channel.send_file returns False by default."""
|
|
|
|
class MinimalChannel(Channel):
|
|
async def start(self):
|
|
pass
|
|
|
|
async def stop(self):
|
|
pass
|
|
|
|
async def send(self, msg):
|
|
pass
|
|
|
|
bus = MessageBus()
|
|
ch = MinimalChannel(name="minimal", bus=bus, config={})
|
|
att = ResolvedAttachment("/x", Path("/x"), "x", "text/plain", 0, False)
|
|
msg = OutboundMessage(channel_name="minimal", chat_id="c", thread_id="t", text="t")
|
|
|
|
result = _run(ch.send_file(msg, att))
|
|
assert result is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ChannelManager artifact resolution integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestManagerArtifactResolution:
|
|
def test_handle_chat_populates_attachments(self):
|
|
"""Verify _resolve_attachments is importable and works with the manager module."""
|
|
from app.channels.manager import _resolve_attachments
|
|
|
|
# Basic smoke test: empty artifacts returns empty list
|
|
mock_paths = MagicMock()
|
|
with patch("deerflow.config.paths.get_paths", return_value=mock_paths):
|
|
result = _resolve_attachments("t1", [])
|
|
assert result == []
|
|
|
|
def test_format_artifact_text_for_unresolved(self):
|
|
"""_format_artifact_text produces expected output."""
|
|
from app.channels.manager import _format_artifact_text
|
|
|
|
assert "report.pdf" in _format_artifact_text(["/mnt/user-data/outputs/report.pdf"])
|
|
result = _format_artifact_text(["/mnt/user-data/outputs/a.txt", "/mnt/user-data/outputs/b.txt"])
|
|
assert "a.txt" in result
|
|
assert "b.txt" in result
|