From 8002e04295320d03a677e0c47765a341cf0619a2 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Fri, 27 Mar 2026 15:01:29 +0530 Subject: [PATCH 01/19] minor --- .codex | 0 .../core/acontext_core/schema/mq/session.py | 2 +- .../service/controller/message.py | 84 ++++++ .../acontext_core/service/data/message.py | 147 ++++++++++- .../acontext_core/service/session_message.py | 27 +- .../tests/service/test_buffer_timer_dedup.py | 193 +++++++++++++- .../service/test_message_data_branch_path.py | 172 ++++++++++++ .../service/test_process_inserted_message.py | 248 ++++++++++++++++++ 8 files changed, 849 insertions(+), 24 deletions(-) create mode 100644 .codex create mode 100644 src/server/core/tests/service/test_message_data_branch_path.py create mode 100644 src/server/core/tests/service/test_process_inserted_message.py diff --git a/.codex b/.codex new file mode 100644 index 000000000..e69de29bb diff --git a/src/server/core/acontext_core/schema/mq/session.py b/src/server/core/acontext_core/schema/mq/session.py index 6d76a06e8..40144e90c 100644 --- a/src/server/core/acontext_core/schema/mq/session.py +++ b/src/server/core/acontext_core/schema/mq/session.py @@ -6,7 +6,7 @@ class InsertNewMessage(BaseModel): project_id: asUUID session_id: asUUID - message_id: asUUID # sent by API but unused by handler + message_id: asUUID # inserted message used as the processing key process_rightnow: bool = False lock_retry_count: int = 0 user_kek: Optional[str] = None # base64-encoded user KEK diff --git a/src/server/core/acontext_core/service/controller/message.py b/src/server/core/acontext_core/service/controller/message.py index 377c2cf00..a682cccc5 100644 --- a/src/server/core/acontext_core/service/controller/message.py +++ b/src/server/core/acontext_core/service/controller/message.py @@ -26,6 +26,90 @@ async def _try_rollback_to_failed(pending_message_ids: list) -> None: ) +async def process_inserted_message( + project_config: ProjectConfig, + project_id: asUUID, + session_id: asUUID, + message_id: asUUID, + user_kek: bytes | None = None, +) -> Result[None]: + wide = get_wide_event() + disabled = await get_metrics(project_id, ExcessMetricTags.new_task_created) + target_message_ids = [message_id] + + try: + async with DB_CLIENT.get_session_context() as session: + if disabled: + wide["project_disabled"] = True + await MD.update_message_status_to( + session, target_message_ids, TaskStatus.LIMIT_EXCEED + ) + return Result.resolve(None) + + wide["project_disabled"] = False + await MD.update_message_status_to( + session, target_message_ids, TaskStatus.RUNNING + ) + + async with DB_CLIENT.get_session_context() as session: + r = await MD.fetch_message_branch_path_data( + session, message_id, session_id, user_kek=user_kek + ) + messages, eil = r.unpack() + if eil: + await _try_rollback_to_failed(target_message_ids) + return r + + messages_data = [ + MessageBlob( + message_id=m.id, role=m.role, parts=m.parts, task_id=m.task_id + ) + for m in messages + ] + + async with DB_CLIENT.get_session_context() as session: + r = await LS.get_learning_space_for_session(session, session_id) + ls_session, eil = r.unpack() + if eil: + ls_session = None + + r = await AT.task_agent_curd( + project_id, + session_id, + messages_data, + max_iterations=project_config.default_task_agent_max_iterations, + previous_progress_num=project_config.default_task_agent_previous_progress_num, + learning_space_id=( + ls_session.learning_space_id if ls_session is not None else None + ), + task_success_criteria=project_config.task_success_criteria, + task_failure_criteria=project_config.task_failure_criteria, + ) + + after_status = TaskStatus.SUCCESS + if not r.ok(): + after_status = TaskStatus.FAILED + wide["task_agent_outcome"] = "failed" + else: + wide["task_agent_outcome"] = "success" + + async with DB_CLIENT.get_session_context() as session: + await MD.update_message_status_to( + session, target_message_ids, after_status + ) + return r + except BaseException as e: + LOG.error( + "inserted_message_exception", + error=str(e) or "(no message)", + error_type=type(e).__name__, + message_id=str(message_id), + ) + wide["task_agent_outcome"] = "exception" + await _try_rollback_to_failed(target_message_ids) + raise + + async def process_session_pending_message( project_config: ProjectConfig, project_id: asUUID, diff --git a/src/server/core/acontext_core/service/data/message.py b/src/server/core/acontext_core/service/data/message.py index b9e377ce1..b7353bd04 100644 --- a/src/server/core/acontext_core/service/data/message.py +++ b/src/server/core/acontext_core/service/data/message.py @@ -1,7 +1,7 @@ import asyncio import json from typing import List -from sqlalchemy import select, func +from sqlalchemy import select, func, text from sqlalchemy.ext.asyncio import AsyncSession from pydantic import ValidationError from datetime import datetime @@ -133,6 +133,151 @@ async def fetch_messages_data_by_ids( return Result.reject(f"Error fetching messages by IDs {message_ids}: {e}") +async def fetch_message_branch_path_ids( + db_session: AsyncSession, + message_id: asUUID, + session_id: asUUID | None = None, +) -> Result[List[asUUID]]: + """ + Fetch one message's branch path from root to the target message. + + Uses a recursive CTE to walk parent_id upward in one query. + + Args: + db_session: Database session + message_id: Leaf or intermediate message UUID to start from + session_id: Optional session UUID to verify the full path belongs to + + Returns: + Result containing message IDs ordered from root to target message + """ + try: + query = text( + """ + WITH RECURSIVE message_path AS ( + SELECT id, parent_id, session_id, 0 AS depth + FROM messages + WHERE id = :message_id + + UNION ALL + + SELECT parent.id, parent.parent_id, parent.session_id, child.depth + 1 AS depth + FROM messages AS parent + JOIN message_path AS child + ON parent.id = child.parent_id + ) + SELECT id, session_id, depth + FROM message_path + ORDER BY depth DESC, id ASC + """ + ) + result = await db_session.execute(query, {"message_id": message_id}) + rows = result.mappings().all() + + if not rows: + return Result.reject(f"Message {message_id} doesn't exist") + + path_ids = [row["id"] for row in rows] + path_session_ids = {row["session_id"] for row in rows} + + if session_id is not None and path_session_ids != {session_id}: + return Result.reject( + f"Message {message_id} does not belong to session {session_id}" + ) + + if len(path_session_ids) != 1: + return Result.reject( + f"Message {message_id} has an invalid cross-session parent chain" + ) + + return Result.resolve(path_ids) + except Exception as e: + return Result.reject( + f"Error fetching branch path for message {message_id}: {e}" + ) + + +async def branch_pending_message_length( + db_session: AsyncSession, + message_id: asUUID, + status: str = "pending", + session_id: asUUID | None = None, +) -> Result[int]: + """ + Count pending messages on one message's branch path from root to target. + + Args: + db_session: Database session + message_id: Target message UUID + status: Status filter for messages on the branch + session_id: Optional session UUID to verify the full path belongs to + + Returns: + Result containing the count of matching messages on the branch path + """ + try: + query = text( + """ + WITH RECURSIVE message_path AS ( + SELECT id, parent_id, session_id, session_task_process_status + FROM messages + WHERE id = :message_id + + UNION ALL + + SELECT parent.id, parent.parent_id, parent.session_id, parent.session_task_process_status + FROM messages AS parent + JOIN message_path AS child + ON parent.id = child.parent_id + ) + SELECT id, session_id, session_task_process_status + FROM message_path + """ + ) + result = await db_session.execute(query, {"message_id": message_id}) + rows = result.mappings().all() + + if not rows: + return Result.reject(f"Message {message_id} doesn't exist") + + path_session_ids = {row["session_id"] for row in rows} + if session_id is not None and path_session_ids != {session_id}: + return Result.reject( + f"Message {message_id} does not belong to session {session_id}" + ) + if len(path_session_ids) != 1: + return Result.reject( + f"Message {message_id} has an invalid cross-session parent chain" + ) + + count = sum( + 1 for row in rows if row["session_task_process_status"] == status + ) + return Result.resolve(count) + except Exception as e: + return Result.reject( + f"Error counting branch messages for message {message_id}: {e}" + ) + + +async def fetch_message_branch_path_data( + db_session: AsyncSession, + message_id: asUUID, + session_id: asUUID | None = None, + user_kek: bytes | None = None, +) -> Result[List[Message]]: + """ + Fetch one message's branch path with parts loaded from S3. + """ + r = await fetch_message_branch_path_ids(db_session, message_id, session_id) + message_ids, eil = r.unpack() + if eil: + return Result.reject(str(eil)) + return await fetch_messages_data_by_ids( + db_session, message_ids, user_kek=user_kek + ) + + async def fetch_session_messages( db_session: AsyncSession, session_id: asUUID, diff --git a/src/server/core/acontext_core/service/session_message.py b/src/server/core/acontext_core/service/session_message.py index b1934b2be..78e2e3391 100644 --- a/src/server/core/acontext_core/service/session_message.py +++ b/src/server/core/acontext_core/service/session_message.py @@ -25,6 +25,10 @@ ) +def _insert_message_lock_key(session_id: asUUID, message_id: asUUID) -> str: + return f"session.message.insert.{session_id}.{message_id}" + + @register_consumer( config=ConsumerConfigData( exchange_name=EX.session_message, @@ -49,12 +53,14 @@ async def insert_new_message(body: InsertNewMessage, message: Message): if eil: return - r = await MD.session_message_length(session, body.session_id) + r = await MD.branch_pending_message_length( + session, body.message_id, session_id=body.session_id + ) pending_count, eil = r.unpack() if eil: return - wide["pending_message_count"] = pending_count + wide["pending_branch_message_count"] = pending_count if ( not body.process_rightnow @@ -70,9 +76,8 @@ async def insert_new_message(body: InsertNewMessage, message: Message): ) return - _l = await check_redis_lock_or_set( - body.project_id, f"session.message.insert.{body.session_id}" - ) + lock_key = _insert_message_lock_key(body.session_id, body.message_id) + _l = await check_redis_lock_or_set(body.project_id, lock_key) if not _l: wide["lock_acquired"] = False wide["action"] = "retry_locked" @@ -116,13 +121,15 @@ async def insert_new_message(body: InsertNewMessage, message: Message): ) else: wide["action"] = "process" - await MC.process_session_pending_message( - project_config, body.project_id, body.session_id, user_kek=user_kek_bytes + await MC.process_inserted_message( + project_config, + body.project_id, + body.session_id, + body.message_id, + user_kek=user_kek_bytes, ) finally: - await release_redis_lock( - body.project_id, f"session.message.insert.{body.session_id}" - ) + await release_redis_lock(body.project_id, lock_key) # Delay queue: holds messages for buffer_ttl seconds, then DLX back to entry. diff --git a/src/server/core/tests/service/test_buffer_timer_dedup.py b/src/server/core/tests/service/test_buffer_timer_dedup.py index 8cf75d622..6ae816f73 100644 --- a/src/server/core/tests/service/test_buffer_timer_dedup.py +++ b/src/server/core/tests/service/test_buffer_timer_dedup.py @@ -10,6 +10,7 @@ """ import json +import base64 import uuid import pytest from unittest.mock import AsyncMock, MagicMock, patch @@ -104,7 +105,7 @@ async def test_skips_stale_delay(self): new_callable=AsyncMock, ) as mock_lock, patch( - "acontext_core.service.session_message.MC.process_session_pending_message", + "acontext_core.service.session_message.MC.process_inserted_message", new_callable=AsyncMock, ) as mock_process, ): @@ -137,7 +138,7 @@ async def capture_publish(**kwargs): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.session_message_length", + "acontext_core.service.session_message.MD.branch_pending_message_length", new_callable=AsyncMock, return_value=Result.resolve(3), ), @@ -175,7 +176,7 @@ async def capture_publish(**kwargs): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.session_message_length", + "acontext_core.service.session_message.MD.branch_pending_message_length", new_callable=AsyncMock, return_value=Result.resolve(3), ), @@ -211,7 +212,7 @@ async def test_processes_immediately_when_at_threshold(self): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.session_message_length", + "acontext_core.service.session_message.MD.branch_pending_message_length", new_callable=AsyncMock, return_value=Result.resolve(16), ), @@ -219,16 +220,22 @@ async def test_processes_immediately_when_at_threshold(self): "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, return_value=True, - ), - patch("acontext_core.service.session_message.release_redis_lock", new_callable=AsyncMock), + ) as mock_lock, + patch( + "acontext_core.service.session_message.release_redis_lock", + new_callable=AsyncMock, + ) as mock_release, patch( - "acontext_core.service.session_message.MC.process_session_pending_message", + "acontext_core.service.session_message.MC.process_inserted_message", new_callable=AsyncMock, return_value=Result.resolve(None), ) as mock_process, ): await insert_new_message(body, MagicMock()) mock_process.assert_called_once() + expected_key = f"session.message.insert.{body.session_id}.{body.message_id}" + mock_lock.assert_called_once_with(body.project_id, expected_key) + mock_release.assert_called_once_with(body.project_id, expected_key) class TestDelayFires: @@ -251,7 +258,47 @@ async def test_processes_when_message_still_pending(self): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.session_message_length", + "acontext_core.service.session_message.MD.branch_pending_message_length", + new_callable=AsyncMock, + return_value=Result.resolve(2), + ), + patch( + "acontext_core.service.session_message.check_redis_lock_or_set", + new_callable=AsyncMock, + return_value=True, + ), + patch("acontext_core.service.session_message.release_redis_lock", new_callable=AsyncMock), + patch( + "acontext_core.service.session_message.MC.process_inserted_message", + new_callable=AsyncMock, + return_value=Result.resolve(None), + ) as mock_process, + ): + await insert_new_message(body, MagicMock()) + mock_process.assert_called_once() + assert mock_process.call_args.kwargs["user_kek"] is None + + @pytest.mark.asyncio + async def test_processes_with_decoded_user_kek(self): + """Valid base64 user_kek is decoded once and forwarded as bytes.""" + body = _make_body(process_rightnow=True) + body.user_kek = base64.b64encode(b"secret-kek").decode() + project_config = _make_project_config() + + with ( + patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()), + patch( + "acontext_core.service.session_message.MD.check_session_message_status", + new_callable=AsyncMock, + return_value=Result.resolve("pending"), + ), + patch( + "acontext_core.service.session_message.PD.get_project_config", + new_callable=AsyncMock, + return_value=Result.resolve(project_config), + ), + patch( + "acontext_core.service.session_message.MD.branch_pending_message_length", new_callable=AsyncMock, return_value=Result.resolve(2), ), @@ -262,13 +309,57 @@ async def test_processes_when_message_still_pending(self): ), patch("acontext_core.service.session_message.release_redis_lock", new_callable=AsyncMock), patch( - "acontext_core.service.session_message.MC.process_session_pending_message", + "acontext_core.service.session_message.MC.process_inserted_message", new_callable=AsyncMock, return_value=Result.resolve(None), ) as mock_process, ): await insert_new_message(body, MagicMock()) mock_process.assert_called_once() + assert mock_process.call_args.kwargs["user_kek"] == b"secret-kek" + + @pytest.mark.asyncio + async def test_invalid_user_kek_marks_session_failed(self): + """Invalid base64 user_kek hard-fails before processing.""" + body = _make_body(process_rightnow=True) + body.user_kek = "not-base64!" + project_config = _make_project_config() + + with ( + patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()) as mock_db, + patch( + "acontext_core.service.session_message.MD.check_session_message_status", + new_callable=AsyncMock, + return_value=Result.resolve("pending"), + ), + patch( + "acontext_core.service.session_message.PD.get_project_config", + new_callable=AsyncMock, + return_value=Result.resolve(project_config), + ), + patch( + "acontext_core.service.session_message.MD.branch_pending_message_length", + new_callable=AsyncMock, + return_value=Result.resolve(2), + ), + patch( + "acontext_core.service.session_message.check_redis_lock_or_set", + new_callable=AsyncMock, + return_value=True, + ), + patch("acontext_core.service.session_message.release_redis_lock", new_callable=AsyncMock), + patch( + "acontext_core.service.session_message.LS.update_session_status", + new_callable=AsyncMock, + ) as mock_update_status, + patch( + "acontext_core.service.session_message.MC.process_inserted_message", + new_callable=AsyncMock, + ) as mock_process, + ): + await insert_new_message(body, MagicMock()) + mock_process.assert_not_called() + mock_update_status.assert_called_once() class TestLockContention: @@ -295,7 +386,7 @@ async def capture_publish(**kwargs): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.session_message_length", + "acontext_core.service.session_message.MD.branch_pending_message_length", new_callable=AsyncMock, return_value=Result.resolve(2), ), @@ -337,7 +428,7 @@ async def capture_publish(**kwargs): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.session_message_length", + "acontext_core.service.session_message.MD.branch_pending_message_length", new_callable=AsyncMock, return_value=Result.resolve(20), ), @@ -381,7 +472,7 @@ async def capture_publish(**kwargs): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.session_message_length", + "acontext_core.service.session_message.MD.branch_pending_message_length", new_callable=AsyncMock, return_value=Result.resolve(3), ), @@ -394,3 +485,81 @@ async def capture_publish(**kwargs): msg1 = json.loads(published[0]["body"]) msg2 = json.loads(published[1]["body"]) assert msg1["session_id"] != msg2["session_id"] + + +class TestBranchAwareCount: + @pytest.mark.asyncio + async def test_consumer_uses_branch_pending_count_not_session_count(self): + """Buffering uses branch count and never calls the old session-wide counter.""" + body = _make_body() + project_config = _make_project_config() + published = [] + + async def capture_publish(**kwargs): + published.append(kwargs) + + with ( + patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()), + patch( + "acontext_core.service.session_message.MD.check_session_message_status", + new_callable=AsyncMock, + return_value=Result.resolve("pending"), + ), + patch( + "acontext_core.service.session_message.PD.get_project_config", + new_callable=AsyncMock, + return_value=Result.resolve(project_config), + ), + patch( + "acontext_core.service.session_message.MD.branch_pending_message_length", + new_callable=AsyncMock, + return_value=Result.resolve(1), + ), + patch( + "acontext_core.service.session_message.MD.session_message_length", + new_callable=AsyncMock, + ) as mock_session_count, + patch("acontext_core.service.session_message.publish_mq", side_effect=capture_publish), + ): + await insert_new_message(body, MagicMock()) + + mock_session_count.assert_not_called() + assert len(published) == 1 + assert published[0]["routing_key"] == "session.message.insert.delay" + + +class TestBranchLockKey: + @pytest.mark.asyncio + async def test_retry_uses_branch_specific_lock_key(self): + """Lock key for insert processing includes the message id.""" + body = _make_body(process_rightnow=True) + project_config = _make_project_config() + + with ( + patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()), + patch( + "acontext_core.service.session_message.MD.check_session_message_status", + new_callable=AsyncMock, + return_value=Result.resolve("pending"), + ), + patch( + "acontext_core.service.session_message.PD.get_project_config", + new_callable=AsyncMock, + return_value=Result.resolve(project_config), + ), + patch( + "acontext_core.service.session_message.MD.branch_pending_message_length", + new_callable=AsyncMock, + return_value=Result.resolve(16), + ), + patch( + "acontext_core.service.session_message.check_redis_lock_or_set", + new_callable=AsyncMock, + return_value=False, + ) as mock_lock, + patch("acontext_core.service.session_message.publish_mq", new_callable=AsyncMock), + ): + await insert_new_message(body, MagicMock()) + + expected_key = f"session.message.insert.{body.session_id}.{body.message_id}" + mock_lock.assert_called_once_with(body.project_id, expected_key) diff --git a/src/server/core/tests/service/test_message_data_branch_path.py b/src/server/core/tests/service/test_message_data_branch_path.py new file mode 100644 index 000000000..9d4306974 --- /dev/null +++ b/src/server/core/tests/service/test_message_data_branch_path.py @@ -0,0 +1,172 @@ +import pytest +from uuid import uuid4 + +from acontext_core.schema.orm import Message, Project, Session +from acontext_core.service.data import message as MD + + +async def _create_project_and_session(db_client): + unique_key = uuid4().hex + async with db_client.get_session_context() as session: + project = Project( + secret_key_hmac=unique_key, + secret_key_hash_phc=unique_key, + ) + session.add(project) + await session.flush() + + acontext_session = Session(project_id=project.id) + session.add(acontext_session) + await session.flush() + + return project, acontext_session + + +@pytest.mark.asyncio +async def test_fetch_message_branch_path_ids_root_only(db_client): + _, acontext_session = await _create_project_and_session(db_client) + + async with db_client.get_session_context() as session: + root = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={}, + ) + session.add(root) + await session.flush() + + r = await MD.fetch_message_branch_path_ids( + session, root.id, acontext_session.id + ) + message_ids, eil = r.unpack() + + assert eil is None + assert message_ids == [root.id] + + +@pytest.mark.asyncio +async def test_fetch_message_branch_path_ids_returns_root_to_leaf_order(db_client): + _, acontext_session = await _create_project_and_session(db_client) + + async with db_client.get_session_context() as session: + root = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={}, + ) + session.add(root) + await session.flush() + + child = Message( + session_id=acontext_session.id, + role="assistant", + parts_asset_meta={}, + parent_id=root.id, + ) + session.add(child) + await session.flush() + + leaf = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={}, + parent_id=child.id, + ) + session.add(leaf) + await session.flush() + + r = await MD.fetch_message_branch_path_ids( + session, leaf.id, acontext_session.id + ) + message_ids, eil = r.unpack() + + assert eil is None + assert message_ids == [root.id, child.id, leaf.id] + + +@pytest.mark.asyncio +async def test_fetch_message_branch_path_ids_rejects_wrong_session(db_client): + _, session_a = await _create_project_and_session(db_client) + _, session_b = await _create_project_and_session(db_client) + + async with db_client.get_session_context() as session: + msg = Message( + session_id=session_a.id, + role="user", + parts_asset_meta={}, + ) + session.add(msg) + await session.flush() + + r = await MD.fetch_message_branch_path_ids(session, msg.id, session_b.id) + _, eil = r.unpack() + + assert eil is not None + assert "does not belong to session" in str(eil) + + +@pytest.mark.asyncio +async def test_fetch_message_branch_path_ids_rejects_missing_message(db_client): + _, acontext_session = await _create_project_and_session(db_client) + + async with db_client.get_session_context() as session: + r = await MD.fetch_message_branch_path_ids( + session, acontext_session.id, acontext_session.id + ) + _, eil = r.unpack() + + assert eil is not None + assert "doesn't exist" in str(eil) + + +@pytest.mark.asyncio +async def test_branch_pending_message_length_ignores_sibling_branches(db_client): + _, acontext_session = await _create_project_and_session(db_client) + + async with db_client.get_session_context() as session: + root = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={}, + session_task_process_status="success", + ) + session.add(root) + await session.flush() + + branch_a = Message( + session_id=acontext_session.id, + role="assistant", + parts_asset_meta={}, + parent_id=root.id, + session_task_process_status="pending", + ) + session.add(branch_a) + await session.flush() + + branch_b = Message( + session_id=acontext_session.id, + role="assistant", + parts_asset_meta={}, + parent_id=root.id, + session_task_process_status="pending", + ) + session.add(branch_b) + await session.flush() + + leaf_a = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={}, + parent_id=branch_a.id, + session_task_process_status="pending", + ) + session.add(leaf_a) + await session.flush() + + r = await MD.branch_pending_message_length( + session, leaf_a.id, session_id=acontext_session.id + ) + pending_count, eil = r.unpack() + + assert eil is None + assert pending_count == 2 diff --git a/src/server/core/tests/service/test_process_inserted_message.py b/src/server/core/tests/service/test_process_inserted_message.py new file mode 100644 index 000000000..d652131fa --- /dev/null +++ b/src/server/core/tests/service/test_process_inserted_message.py @@ -0,0 +1,248 @@ +""" +Tests for process_inserted_message. + +Covers: +- success path with branch-only messages +- branch-load error rollback +- task-agent exception rollback +""" + +import asyncio +import uuid +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from acontext_core.schema.result import Result +from acontext_core.schema.session.task import TaskStatus +from acontext_core.service.controller.message import process_inserted_message + + +MODULE = "acontext_core.service.controller.message" + +_PROJECT_ID = uuid.uuid4() +_SESSION_ID = uuid.uuid4() +_MESSAGE_ID = uuid.uuid4() + + +def _mock_db(): + mock_db = MagicMock() + mock_db.get_session_context.return_value.__aenter__ = AsyncMock( + return_value=MagicMock() + ) + mock_db.get_session_context.return_value.__aexit__ = AsyncMock(return_value=False) + return mock_db + + +def _mock_project_config(): + config = MagicMock() + config.default_task_agent_max_iterations = 3 + config.default_task_agent_previous_progress_num = 5 + config.task_success_criteria = [] + config.task_failure_criteria = [] + return config + + +def _make_message(message_id: uuid.UUID, role: str): + message = MagicMock() + message.id = message_id + message.role = role + message.parts = [] + message.task_id = None + return message + + +class TestProcessInsertedMessage: + @pytest.mark.asyncio + async def test_marks_inserted_message_running_then_success(self): + update_status = AsyncMock() + branch_messages = [ + _make_message(uuid.uuid4(), "user"), + _make_message(_MESSAGE_ID, "assistant"), + ] + + with ( + patch(f"{MODULE}.DB_CLIENT", _mock_db()), + patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), + patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch( + f"{MODULE}.MD.update_message_status_to", + update_status, + ), + patch( + f"{MODULE}.MD.fetch_message_branch_path_data", + new_callable=AsyncMock, + return_value=Result.resolve(branch_messages), + ), + patch( + f"{MODULE}.LS.get_learning_space_for_session", + new_callable=AsyncMock, + return_value=Result.resolve(None), + ), + patch( + f"{MODULE}.AT.task_agent_curd", + new_callable=AsyncMock, + return_value=Result.resolve(None), + ) as mock_task_agent, + ): + result = await process_inserted_message( + _mock_project_config(), + _PROJECT_ID, + _SESSION_ID, + _MESSAGE_ID, + user_kek=b"secret-kek", + ) + + assert result.ok() + assert update_status.call_args_list[0].args[1:] == ( + [_MESSAGE_ID], + TaskStatus.RUNNING, + ) + assert update_status.call_args_list[-1].args[1:] == ( + [_MESSAGE_ID], + TaskStatus.SUCCESS, + ) + + task_messages = mock_task_agent.call_args.args[2] + assert [message.message_id for message in task_messages] == [ + branch_messages[0].id, + branch_messages[1].id, + ] + + @pytest.mark.asyncio + async def test_forwards_user_kek_to_branch_path_fetch(self): + update_status = AsyncMock() + branch_messages = [ + _make_message(uuid.uuid4(), "user"), + _make_message(_MESSAGE_ID, "assistant"), + ] + + with ( + patch(f"{MODULE}.DB_CLIENT", _mock_db()), + patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), + patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch(f"{MODULE}.MD.update_message_status_to", update_status), + patch( + f"{MODULE}.MD.fetch_message_branch_path_data", + new_callable=AsyncMock, + return_value=Result.resolve(branch_messages), + ) as mock_fetch_branch, + patch( + f"{MODULE}.LS.get_learning_space_for_session", + new_callable=AsyncMock, + return_value=Result.resolve(None), + ), + patch( + f"{MODULE}.AT.task_agent_curd", + new_callable=AsyncMock, + return_value=Result.resolve(None), + ), + ): + result = await process_inserted_message( + _mock_project_config(), + _PROJECT_ID, + _SESSION_ID, + _MESSAGE_ID, + user_kek=b"secret-kek", + ) + + assert result.ok() + mock_fetch_branch.assert_called_once_with( + pytest.ANY, + _MESSAGE_ID, + _SESSION_ID, + user_kek=b"secret-kek", + ) + + @pytest.mark.asyncio + async def test_branch_load_error_rolls_back_only_inserted_message(self): + update_status = AsyncMock() + + with ( + patch(f"{MODULE}.DB_CLIENT", _mock_db()), + patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), + patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch( + f"{MODULE}.MD.update_message_status_to", + update_status, + ), + patch( + f"{MODULE}.MD.fetch_message_branch_path_data", + new_callable=AsyncMock, + return_value=Result.reject("branch lookup failed"), + ), + patch( + f"{MODULE}.LS.get_learning_space_for_session", + new_callable=AsyncMock, + ) as mock_ls, + patch( + f"{MODULE}.AT.task_agent_curd", + new_callable=AsyncMock, + ) as mock_task_agent, + ): + result = await process_inserted_message( + _mock_project_config(), + _PROJECT_ID, + _SESSION_ID, + _MESSAGE_ID, + ) + + assert not result.ok() + assert update_status.call_args_list[0].args[1:] == ( + [_MESSAGE_ID], + TaskStatus.RUNNING, + ) + assert update_status.call_args_list[-1].args[1:] == ( + [_MESSAGE_ID], + TaskStatus.FAILED, + ) + mock_ls.assert_not_called() + mock_task_agent.assert_not_called() + + @pytest.mark.asyncio + async def test_task_agent_exception_rolls_back_only_inserted_message(self): + update_status = AsyncMock() + branch_messages = [ + _make_message(uuid.uuid4(), "user"), + _make_message(_MESSAGE_ID, "assistant"), + ] + + with ( + patch(f"{MODULE}.DB_CLIENT", _mock_db()), + patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), + patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch( + f"{MODULE}.MD.update_message_status_to", + update_status, + ), + patch( + f"{MODULE}.MD.fetch_message_branch_path_data", + new_callable=AsyncMock, + return_value=Result.resolve(branch_messages), + ), + patch( + f"{MODULE}.LS.get_learning_space_for_session", + new_callable=AsyncMock, + return_value=Result.resolve(None), + ), + patch( + f"{MODULE}.AT.task_agent_curd", + new_callable=AsyncMock, + side_effect=asyncio.CancelledError(), + ), + ): + with pytest.raises(asyncio.CancelledError): + await process_inserted_message( + _mock_project_config(), + _PROJECT_ID, + _SESSION_ID, + _MESSAGE_ID, + ) + + assert update_status.call_args_list[0].args[1:] == ( + [_MESSAGE_ID], + TaskStatus.RUNNING, + ) + assert update_status.call_args_list[-1].args[1:] == ( + [_MESSAGE_ID], + TaskStatus.FAILED, + ) From 2aa875ac98468e0a67190c443090637709d65c54 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Fri, 27 Mar 2026 15:15:06 +0530 Subject: [PATCH 02/19] blob add parent id --- .../core/acontext_core/llm/agent/task.py | 3 +- .../acontext_core/schema/session/message.py | 1 + .../service/controller/message.py | 12 +++++-- .../service/controller/skill_learner.py | 6 +++- .../core/tests/llm/test_task_message_pack.py | 32 +++++++++++++++++++ .../service/test_process_inserted_message.py | 17 +++++++--- 6 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 src/server/core/tests/llm/test_task_message_pack.py diff --git a/src/server/core/acontext_core/llm/agent/task.py b/src/server/core/acontext_core/llm/agent/task.py index fe123f74b..05ba3a233 100644 --- a/src/server/core/acontext_core/llm/agent/task.py +++ b/src/server/core/acontext_core/llm/agent/task.py @@ -83,7 +83,8 @@ def pack_current_message_with_ids(messages: list[MessageBlob]) -> str: tool_mappings = {} return "\n".join( [ - f" {m.to_string(tool_mappings, truncate_chars=1024)} " + f" " + f"{m.to_string(tool_mappings, truncate_chars=1024)} " for i, m in enumerate(messages) ] ) diff --git a/src/server/core/acontext_core/schema/session/message.py b/src/server/core/acontext_core/schema/session/message.py index bfef20549..042aad0ce 100644 --- a/src/server/core/acontext_core/schema/session/message.py +++ b/src/server/core/acontext_core/schema/session/message.py @@ -68,6 +68,7 @@ def pack_part_line( class MessageBlob(BaseModel): message_id: asUUID + parent_id: Optional[asUUID] = None role: str parts: List[Part] task_id: Optional[asUUID] = None diff --git a/src/server/core/acontext_core/service/controller/message.py b/src/server/core/acontext_core/service/controller/message.py index a682cccc5..a1d784978 100644 --- a/src/server/core/acontext_core/service/controller/message.py +++ b/src/server/core/acontext_core/service/controller/message.py @@ -62,7 +62,11 @@ async def process_inserted_message( messages_data = [ MessageBlob( - message_id=m.id, role=m.role, parts=m.parts, task_id=m.task_id + message_id=m.id, + parent_id=m.parent_id, + role=m.role, + parts=m.parts, + task_id=m.task_id, ) for m in messages ] @@ -170,7 +174,11 @@ async def process_session_pending_message( ) messages_data = [ MessageBlob( - message_id=m.id, role=m.role, parts=m.parts, task_id=m.task_id + message_id=m.id, + parent_id=m.parent_id, + role=m.role, + parts=m.parts, + task_id=m.task_id, ) for m in messages ] diff --git a/src/server/core/acontext_core/service/controller/skill_learner.py b/src/server/core/acontext_core/service/controller/skill_learner.py index 4d67a471e..c28087d46 100644 --- a/src/server/core/acontext_core/service/controller/skill_learner.py +++ b/src/server/core/acontext_core/service/controller/skill_learner.py @@ -73,7 +73,11 @@ async def process_context_distillation( if not eil and messages: task_messages = [ MessageBlob( - message_id=m.id, role=m.role, parts=m.parts, task_id=m.task_id + message_id=m.id, + parent_id=m.parent_id, + role=m.role, + parts=m.parts, + task_id=m.task_id, ) for m in messages ] diff --git a/src/server/core/tests/llm/test_task_message_pack.py b/src/server/core/tests/llm/test_task_message_pack.py new file mode 100644 index 000000000..665545192 --- /dev/null +++ b/src/server/core/tests/llm/test_task_message_pack.py @@ -0,0 +1,32 @@ +from uuid import uuid4 + +from acontext_core.llm.agent.task import pack_current_message_with_ids +from acontext_core.schema.orm.message import Part +from acontext_core.schema.session.message import MessageBlob + + +def test_pack_current_message_with_ids_includes_parent_linkage(): + root_id = uuid4() + child_id = uuid4() + + messages = [ + MessageBlob( + message_id=root_id, + parent_id=None, + role="user", + parts=[Part(type="text", text="root")], + ), + MessageBlob( + message_id=child_id, + parent_id=root_id, + role="assistant", + parts=[Part(type="text", text="child")], + ), + ] + + packed = pack_current_message_with_ids(messages) + + assert f"message_id={root_id}" in packed + assert "parent_id=None" in packed + assert f"message_id={child_id}" in packed + assert f"parent_id={root_id}" in packed diff --git a/src/server/core/tests/service/test_process_inserted_message.py b/src/server/core/tests/service/test_process_inserted_message.py index d652131fa..d329047a9 100644 --- a/src/server/core/tests/service/test_process_inserted_message.py +++ b/src/server/core/tests/service/test_process_inserted_message.py @@ -42,9 +42,10 @@ def _mock_project_config(): return config -def _make_message(message_id: uuid.UUID, role: str): +def _make_message(message_id: uuid.UUID, role: str, parent_id: uuid.UUID | None = None): message = MagicMock() message.id = message_id + message.parent_id = parent_id message.role = role message.parts = [] message.task_id = None @@ -55,9 +56,10 @@ class TestProcessInsertedMessage: @pytest.mark.asyncio async def test_marks_inserted_message_running_then_success(self): update_status = AsyncMock() + root_id = uuid.uuid4() branch_messages = [ - _make_message(uuid.uuid4(), "user"), - _make_message(_MESSAGE_ID, "assistant"), + _make_message(root_id, "user"), + _make_message(_MESSAGE_ID, "assistant", parent_id=root_id), ] with ( @@ -107,6 +109,10 @@ async def test_marks_inserted_message_running_then_success(self): branch_messages[0].id, branch_messages[1].id, ] + assert [message.parent_id for message in task_messages] == [ + None, + root_id, + ] @pytest.mark.asyncio async def test_forwards_user_kek_to_branch_path_fetch(self): @@ -201,9 +207,10 @@ async def test_branch_load_error_rolls_back_only_inserted_message(self): @pytest.mark.asyncio async def test_task_agent_exception_rolls_back_only_inserted_message(self): update_status = AsyncMock() + root_id = uuid.uuid4() branch_messages = [ - _make_message(uuid.uuid4(), "user"), - _make_message(_MESSAGE_ID, "assistant"), + _make_message(root_id, "user"), + _make_message(_MESSAGE_ID, "assistant", parent_id=root_id), ] with ( From fa613b2ed9d6e833690043c5084b224bc20b1ff1 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Fri, 27 Mar 2026 15:51:16 +0530 Subject: [PATCH 03/19] make tasks branch compatible --- .../core/acontext_core/llm/agent/task.py | 32 +++++++++- .../core/acontext_core/llm/prompt/task.py | 8 +++ .../acontext_core/llm/tool/task_lib/append.py | 7 ++- .../llm/tool/task_lib/append_planning.py | 7 ++- .../acontext_core/llm/tool/task_lib/ctx.py | 3 + src/server/core/tests/llm/test_task_agent.py | 59 ++++++++++++++++++- 6 files changed, 107 insertions(+), 9 deletions(-) diff --git a/src/server/core/acontext_core/llm/agent/task.py b/src/server/core/acontext_core/llm/agent/task.py index 05ba3a233..42b1a8a4d 100644 --- a/src/server/core/acontext_core/llm/agent/task.py +++ b/src/server/core/acontext_core/llm/agent/task.py @@ -83,13 +83,27 @@ def pack_current_message_with_ids(messages: list[MessageBlob]) -> str: tool_mappings = {} return "\n".join( [ - f" " + f" " f"{m.to_string(tool_mappings, truncate_chars=1024)} " for i, m in enumerate(messages) ] ) +def pack_current_branch_context(messages: list[MessageBlob]) -> str: + if not messages: + return "- root_message_id: none\n- leaf_message_id: none\n- path_length: 0" + + return "\n".join( + [ + f"- root_message_id: {messages[0].message_id}", + f"- leaf_message_id: {messages[-1].message_id}", + f"- path_length: {len(messages)}", + "- message ids below are branch indexes on this root-to-leaf path", + ] + ) + + async def build_task_ctx( db_session: AsyncSession, project_id: asUUID, @@ -97,8 +111,17 @@ async def build_task_ctx( messages: list[MessageBlob], before_use_ctx: TaskCtx = None, ) -> TaskCtx: + message_ids_index = [m.message_id for m in messages] + message_parent_ids_index = [m.parent_id for m in messages] + branch_root_message_id = messages[0].message_id if messages else None + branch_leaf_message_id = messages[-1].message_id if messages else None + if before_use_ctx is not None: before_use_ctx.db_session = db_session + before_use_ctx.message_ids_index = message_ids_index + before_use_ctx.message_parent_ids_index = message_parent_ids_index + before_use_ctx.branch_root_message_id = branch_root_message_id + before_use_ctx.branch_leaf_message_id = branch_leaf_message_id return before_use_ctx r = await TD.fetch_current_tasks(db_session, session_id) @@ -111,7 +134,10 @@ async def build_task_ctx( session_id=session_id, task_ids_index=[t.id for t in current_tasks], task_index=current_tasks, - message_ids_index=[m.message_id for m in messages], + message_ids_index=message_ids_index, + message_parent_ids_index=message_parent_ids_index, + branch_root_message_id=branch_root_message_id, + branch_leaf_message_id=branch_leaf_message_id, ) return use_ctx @@ -162,6 +188,7 @@ async def task_agent_curd( tasks, previous_progress_num ) current_messages_section = pack_current_message_with_ids(messages) + branch_context_section = pack_current_branch_context(messages) json_tools = [tool.model_dump() for tool in TaskPrompt.tool_schema()] already_iterations = 0 @@ -172,6 +199,7 @@ async def task_agent_curd( "role": "user", "content": TaskPrompt.pack_task_input( previous_progress_section, + branch_context_section, current_messages_section, task_section, known_preferences=known_preferences or None, diff --git a/src/server/core/acontext_core/llm/prompt/task.py b/src/server/core/acontext_core/llm/prompt/task.py index dd4313160..f0af4c3c8 100644 --- a/src/server/core/acontext_core/llm/prompt/task.py +++ b/src/server/core/acontext_core/llm/prompt/task.py @@ -18,7 +18,10 @@ def system_prompt(cls) -> str: - `## Current Existing Tasks`: existing tasks with orders, descriptions, and statuses - `## Previous Progress`: context from prior task progress - `## Known User Preferences`: previously submitted user preferences (if any) — do not re-submit these +- `## Current Branch Path`: root, leaf, and path details for the current branch - `## Current Message with IDs`: messages to analyze, formatted as `content` + - These messages are one branch path ordered from root to leaf + - `id=N` is the branch index used by task tools, not a session-wide message id ## Workflow @@ -41,6 +44,7 @@ def system_prompt(cls) -> str: ### 3. Link Messages to Tasks - Use `append_messages_to_task` with a `message_id_range` [start, end] to link a range of message IDs to the relevant task +- The range refers to branch indexes from the current root-to-leaf path - This tool ONLY links messages and auto-sets the task status to `running` — it does NOT record progress or preferences - Only link messages that directly contribute to a task (no random linking) @@ -107,6 +111,7 @@ def system_prompt(cls) -> str: def pack_task_input( cls, previous_progress: str, + branch_context: str, current_message_with_ids: str, current_tasks: str, known_preferences: list[str] = None, @@ -135,6 +140,9 @@ def pack_task_input( ## Previous Progress: {previous_progress} {known_prefs_section}{eval_criteria_section} +## Current Branch Path: +{branch_context} + ## Current Message with IDs: {current_message_with_ids} diff --git a/src/server/core/acontext_core/llm/tool/task_lib/append.py b/src/server/core/acontext_core/llm/tool/task_lib/append.py index de2f086cc..e452e5136 100644 --- a/src/server/core/acontext_core/llm/tool/task_lib/append.py +++ b/src/server/core/acontext_core/llm/tool/task_lib/append.py @@ -76,9 +76,10 @@ async def _append_messages_to_task_handler( ToolSchema( function={ "name": "append_messages_to_task", - "description": """Link a range of message ids to a task. This tool ONLY links messages and auto-sets the task status to 'running'. + "description": """Link a range of current branch-path messages to a task. This tool ONLY links messages and auto-sets the task status to 'running'. - Use separate tools for recording progress (append_task_progress) and user preferences (submit_user_preference). -- If you decide to link messages to a task marked as 'success' or 'failed', update its status to 'running' first.""", +- If you decide to link messages to a task marked as 'success' or 'failed', update its status to 'running' first. +- The range values are branch indexes from the current root-to-leaf path, not session-wide message IDs.""", "parameters": { "type": "object", "properties": { @@ -91,7 +92,7 @@ async def _append_messages_to_task_handler( "items": {"type": "integer"}, "minItems": 2, "maxItems": 2, - "description": "Inclusive range [start, end] of message IDs to link. E.g. [2, 8] links messages 2,3,4,5,6,7,8.", + "description": "Inclusive range [start, end] of branch indexes to link from the current root-to-leaf path. E.g. [2, 4] links the 3rd, 4th, and 5th messages in the current branch path.", }, }, "required": ["task_order", "message_id_range"], diff --git a/src/server/core/acontext_core/llm/tool/task_lib/append_planning.py b/src/server/core/acontext_core/llm/tool/task_lib/append_planning.py index 9231a15d6..d8c41b8ba 100644 --- a/src/server/core/acontext_core/llm/tool/task_lib/append_planning.py +++ b/src/server/core/acontext_core/llm/tool/task_lib/append_planning.py @@ -38,15 +38,16 @@ async def _append_messages_to_planning_section_handler( ToolSchema( function={ "name": "append_messages_to_planning_section", - "description": """Save current message ids to the planning section. -Use this when messages are about the agent/user is planning general plan, and those messages aren't related to any specific task execution.""", + "description": """Save current branch-path messages to the planning section. +Use this when messages are about the agent/user is planning general plan, and those messages aren't related to any specific task execution. +The provided values are branch indexes from the current root-to-leaf path, not session-wide message IDs.""", "parameters": { "type": "object", "properties": { "message_ids": { "type": "array", "items": {"type": "integer"}, - "description": "List of message IDs to append to the planning section.", + "description": "List of branch indexes to append from the current root-to-leaf path.", } }, "required": ["message_ids"], diff --git a/src/server/core/acontext_core/llm/tool/task_lib/ctx.py b/src/server/core/acontext_core/llm/tool/task_lib/ctx.py index 1297f41eb..46f329c51 100644 --- a/src/server/core/acontext_core/llm/tool/task_lib/ctx.py +++ b/src/server/core/acontext_core/llm/tool/task_lib/ctx.py @@ -12,5 +12,8 @@ class TaskCtx: task_ids_index: list[asUUID] task_index: list[TaskSchema] message_ids_index: list[asUUID] + message_parent_ids_index: list[asUUID | None] = field(default_factory=list) + branch_root_message_id: asUUID | None = None + branch_leaf_message_id: asUUID | None = None learning_task_ids: list[asUUID] = field(default_factory=list) pending_preferences: list[str] = field(default_factory=list) diff --git a/src/server/core/tests/llm/test_task_agent.py b/src/server/core/tests/llm/test_task_agent.py index 5d8425579..865c53457 100644 --- a/src/server/core/tests/llm/test_task_agent.py +++ b/src/server/core/tests/llm/test_task_agent.py @@ -13,12 +13,15 @@ from acontext_core.schema.result import Result from acontext_core.schema.session.task import TaskSchema, TaskData, TaskStatus +from acontext_core.schema.session.message import MessageBlob +from acontext_core.schema.orm.message import Part from acontext_core.llm.tool.task_lib.ctx import TaskCtx from acontext_core.llm.tool.task_lib.progress import _append_task_progress_handler from acontext_core.llm.tool.task_lib.submit_preference import ( _submit_user_preference_handler, ) from acontext_core.llm.tool.task_lib.append import _append_messages_to_task_handler +from acontext_core.llm.agent.task import build_task_ctx def _make_ctx( @@ -452,6 +455,7 @@ def test_includes_known_preferences_section(self): result = TaskPrompt.pack_task_input( previous_progress="progress here", + branch_context="branch here", current_message_with_ids="messages here", current_tasks="tasks here", known_preferences=["prefers TypeScript", "uses VS Code"], @@ -465,6 +469,7 @@ def test_omits_section_when_no_preferences(self): result = TaskPrompt.pack_task_input( previous_progress="progress here", + branch_context="branch here", current_message_with_ids="messages here", current_tasks="tasks here", known_preferences=None, @@ -476,6 +481,7 @@ def test_omits_section_when_empty_list(self): result = TaskPrompt.pack_task_input( previous_progress="progress here", + branch_context="branch here", current_message_with_ids="messages here", current_tasks="tasks here", known_preferences=[], @@ -487,14 +493,65 @@ def test_section_between_progress_and_messages(self): result = TaskPrompt.pack_task_input( previous_progress="progress here", + branch_context="branch here", current_message_with_ids="messages here", current_tasks="tasks here", known_preferences=["pref1"], ) progress_pos = result.index("progress here") prefs_pos = result.index("Known User Preferences") + branch_pos = result.index("Current Branch Path") messages_pos = result.index("messages here") - assert progress_pos < prefs_pos < messages_pos + assert progress_pos < prefs_pos < branch_pos < messages_pos + + def test_includes_branch_context_section(self): + from acontext_core.llm.prompt.task import TaskPrompt + + result = TaskPrompt.pack_task_input( + previous_progress="progress here", + branch_context="- root_message_id: root-1\n- leaf_message_id: leaf-1", + current_message_with_ids="messages here", + current_tasks="tasks here", + ) + assert "## Current Branch Path:" in result + assert "root_message_id: root-1" in result + assert "leaf_message_id: leaf-1" in result + + +class TestBuildTaskCtxBranchMetadata: + @pytest.mark.asyncio + async def test_sets_branch_root_leaf_and_parent_indexes(self): + db_session = AsyncMock() + project_id = uuid.uuid4() + session_id = uuid.uuid4() + root_id = uuid.uuid4() + leaf_id = uuid.uuid4() + messages = [ + MessageBlob( + message_id=root_id, + parent_id=None, + role="user", + parts=[Part(type="text", text="root")], + ), + MessageBlob( + message_id=leaf_id, + parent_id=root_id, + role="assistant", + parts=[Part(type="text", text="leaf")], + ), + ] + + with patch( + "acontext_core.llm.agent.task.TD.fetch_current_tasks", + new_callable=AsyncMock, + return_value=Result.resolve([]), + ): + ctx = await build_task_ctx(db_session, project_id, session_id, messages) + + assert ctx.message_ids_index == [root_id, leaf_id] + assert ctx.message_parent_ids_index == [None, root_id] + assert ctx.branch_root_message_id == root_id + assert ctx.branch_leaf_message_id == leaf_id # ============================================================================= From 57055b7baadf6fb4083c5458e5d32d11081df3e8 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Fri, 27 Mar 2026 16:15:21 +0530 Subject: [PATCH 04/19] task branch compatible --- .../service/controller/message.py | 2 - .../acontext_core/service/data/message.py | 5 +- .../acontext_core/service/session_message.py | 106 +++-- .../core/tests/service/test_flush_retries.py | 185 +++++---- .../test_process_session_pending_message.py | 382 ------------------ 5 files changed, 192 insertions(+), 488 deletions(-) delete mode 100644 src/server/core/tests/service/test_process_session_pending_message.py diff --git a/src/server/core/acontext_core/service/controller/message.py b/src/server/core/acontext_core/service/controller/message.py index a1d784978..91d71e188 100644 --- a/src/server/core/acontext_core/service/controller/message.py +++ b/src/server/core/acontext_core/service/controller/message.py @@ -112,8 +112,6 @@ async def process_inserted_message( wide["task_agent_outcome"] = "exception" await _try_rollback_to_failed(target_message_ids) raise - - async def process_session_pending_message( project_config: ProjectConfig, project_id: asUUID, diff --git a/src/server/core/acontext_core/service/data/message.py b/src/server/core/acontext_core/service/data/message.py index b7353bd04..e69121bfe 100644 --- a/src/server/core/acontext_core/service/data/message.py +++ b/src/server/core/acontext_core/service/data/message.py @@ -320,7 +320,7 @@ async def get_message_ids( db_session: AsyncSession, session_id: asUUID, status: str = "pending", - limit: int = 1, + limit: int | None = 1, asc: bool = False, ) -> Result[List[asUUID]]: query = ( @@ -330,8 +330,9 @@ async def get_message_ids( Message.session_task_process_status == status, ) .order_by(Message.created_at.asc() if asc else Message.created_at.desc()) - .limit(limit) ) + if limit is not None: + query = query.limit(limit) result = await db_session.execute(query) message_ids = list(result.scalars().all()) diff --git a/src/server/core/acontext_core/service/session_message.py b/src/server/core/acontext_core/service/session_message.py index 78e2e3391..00ce4881a 100644 --- a/src/server/core/acontext_core/service/session_message.py +++ b/src/server/core/acontext_core/service/session_message.py @@ -29,6 +29,11 @@ def _insert_message_lock_key(session_id: asUUID, message_id: asUUID) -> str: return f"session.message.insert.{session_id}.{message_id}" +async def _get_pending_session_message_ids(session_id: asUUID) -> Result[list[asUUID]]: + async with DB_CLIENT.get_session_context() as session: + return await MD.get_message_ids(session, session_id, limit=None, asc=True) + + @register_consumer( config=ConsumerConfigData( exchange_name=EX.session_message, @@ -174,39 +179,82 @@ async def flush_session_message_blocking( max_retries = DEFAULT_CORE_CONFIG.session_message_flush_max_retries try: - for _attempt in range(max_retries): - _l = await check_redis_lock_or_set( - project_id, f"session.message.insert.{session_id}" - ) - if _l: - break + async with DB_CLIENT.get_session_context() as read_session: + r = await PD.get_project_config(read_session, project_id) + project_config, eil = r.unpack() + if eil: + wide_event["outcome"] = "error" + wide_event["error"] = str(eil) + return r + + retry_count = 0 + processed_count = 0 + while retry_count < max_retries: + r = await _get_pending_session_message_ids(session_id) + pending_message_ids, eil = r.unpack() + if eil: + wide_event["outcome"] = "error" + wide_event["error"] = str(eil) + return r + if not pending_message_ids: + wide_event["outcome"] = "success" + wide_event["processed_count"] = processed_count + wide_event["lock_retries"] = retry_count + return Result.resolve(None) + + wide_event["pending_count"] = len(pending_message_ids) + processed_this_round = False + busy_lock_count = 0 + + for message_id in pending_message_ids: + async with DB_CLIENT.get_session_context() as read_session: + r = await MD.check_session_message_status(read_session, message_id) + msg_status, eil = r.unpack() + if eil: + wide_event["outcome"] = "error" + wide_event["error"] = str(eil) + return r + if msg_status != "pending": + continue + + lock_key = _insert_message_lock_key(session_id, message_id) + _l = await check_redis_lock_or_set(project_id, lock_key) + if not _l: + busy_lock_count += 1 + continue + + try: + r = await MC.process_inserted_message( + project_config, project_id, session_id, message_id + ) + if not r.ok(): + wide_event["outcome"] = "failed" + wide_event["processed_count"] = processed_count + wide_event["lock_retries"] = retry_count + return r + processed_this_round = True + processed_count += 1 + finally: + await release_redis_lock(project_id, lock_key) + + wide_event["busy_lock_count"] = busy_lock_count + wide_event["processed_count"] = processed_count + + if processed_this_round: + retry_count = 0 + continue + + retry_count += 1 + wide_event["lock_retries"] = retry_count await asyncio.sleep( DEFAULT_CORE_CONFIG.session_message_session_lock_wait_seconds ) - else: - wide_event["outcome"] = "retries_exhausted" - wide_event["lock_retries"] = max_retries - return Result.reject( - f"Failed to acquire session lock after {max_retries} retries" - ) - wide_event["lock_retries"] = _attempt - - try: - async with DB_CLIENT.get_session_context() as read_session: - r = await PD.get_project_config(read_session, project_id) - project_config, eil = r.unpack() - if eil: - wide_event["outcome"] = "error" - wide_event["error"] = str(eil) - return r - r = await MC.process_session_pending_message( - project_config, project_id, session_id - ) - wide_event["outcome"] = "success" if r.ok() else "failed" - return r - finally: - await release_redis_lock(project_id, f"session.message.insert.{session_id}") + wide_event["outcome"] = "retries_exhausted" + wide_event["lock_retries"] = max_retries + return Result.reject( + f"Failed to flush pending session messages after {max_retries} retries" + ) finally: wide_event["duration_ms"] = round((perf_counter() - _start) * 1000, 2) LOG.info("flush.message.processed", **wide_event) diff --git a/src/server/core/tests/service/test_flush_retries.py b/src/server/core/tests/service/test_flush_retries.py index 28347f238..9a3f0ded0 100644 --- a/src/server/core/tests/service/test_flush_retries.py +++ b/src/server/core/tests/service/test_flush_retries.py @@ -1,147 +1,184 @@ """ -Tests for Fix 2: Bounded flush retries in flush_session_message_blocking. +Tests for branch-aware flush behavior in flush_session_message_blocking. -Verifies that the flush loop has a max retry count and returns Result.reject() -when exhausted, preventing infinite spin on a stuck Redis lock. +Verifies: +- flush processes pending messages one by one through process_inserted_message +- flush uses branch-specific message lock keys +- flush retries when pending messages remain but branch locks are busy +- flush returns bounded retry errors when no branch can be processed """ import uuid import pytest -from unittest.mock import AsyncMock, patch, MagicMock +from unittest.mock import ANY, AsyncMock, MagicMock, patch, call + from acontext_core.service.session_message import flush_session_message_blocking from acontext_core.schema.result import Result -class TestFlushSucceedsWithinRetries: - """Fix 2 — Flush succeeds within retries.""" +def _mock_db(): + mock_db = MagicMock() + mock_db.get_session_context.return_value.__aenter__ = AsyncMock( + return_value=MagicMock() + ) + mock_db.get_session_context.return_value.__aexit__ = AsyncMock(return_value=False) + return mock_db + +class TestFlushBranchAwareProcessing: @pytest.mark.asyncio - async def test_lock_acquired_on_first_attempt(self): - """Lock acquired immediately — flush processes normally.""" + async def test_processes_each_pending_message_individually(self): project_id = uuid.uuid4() session_id = uuid.uuid4() + message_a = uuid.uuid4() + message_b = uuid.uuid4() with ( + patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()), patch( - "acontext_core.service.session_message.check_redis_lock_or_set", + "acontext_core.service.session_message.PD.get_project_config", new_callable=AsyncMock, - return_value=True, - ) as mock_lock, + return_value=Result.resolve(MagicMock()), + ), patch( - "acontext_core.service.session_message.release_redis_lock", + "acontext_core.service.session_message._get_pending_session_message_ids", new_callable=AsyncMock, + side_effect=[ + Result.resolve([message_a, message_b]), + Result.resolve([]), + ], ), patch( - "acontext_core.service.session_message.PD.get_project_config", + "acontext_core.service.session_message.MD.check_session_message_status", new_callable=AsyncMock, - return_value=Result.resolve(MagicMock()), + return_value=Result.resolve("pending"), ), patch( - "acontext_core.service.session_message.MC.process_session_pending_message", + "acontext_core.service.session_message.check_redis_lock_or_set", + new_callable=AsyncMock, + return_value=True, + ) as mock_lock, + patch( + "acontext_core.service.session_message.release_redis_lock", + new_callable=AsyncMock, + ) as mock_release, + patch( + "acontext_core.service.session_message.MC.process_inserted_message", new_callable=AsyncMock, return_value=Result.resolve(None), - ), - patch("acontext_core.service.session_message.DB_CLIENT") as mock_db, + ) as mock_process, ): - mock_db.get_session_context.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_db.get_session_context.return_value.__aexit__ = AsyncMock(return_value=False) - result = await flush_session_message_blocking(project_id, session_id) assert result.ok() - mock_lock.assert_called_once() + assert mock_process.call_args_list == [ + call(ANY, project_id, session_id, message_a), + call(ANY, project_id, session_id, message_b), + ] + assert mock_lock.call_args_list == [ + call(project_id, f"session.message.insert.{session_id}.{message_a}"), + call(project_id, f"session.message.insert.{session_id}.{message_b}"), + ] + assert mock_release.call_args_list == [ + call(project_id, f"session.message.insert.{session_id}.{message_a}"), + call(project_id, f"session.message.insert.{session_id}.{message_b}"), + ] @pytest.mark.asyncio - async def test_lock_acquired_after_n_failures(self): - """Lock fails N times then succeeds — flush eventually processes.""" + async def test_retries_when_branch_lock_is_busy_then_processes(self): project_id = uuid.uuid4() session_id = uuid.uuid4() - fail_count = 5 - - # Fail 5 times, then succeed - lock_results = [False] * fail_count + [True] + message_id = uuid.uuid4() with ( + patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()), patch( - "acontext_core.service.session_message.check_redis_lock_or_set", + "acontext_core.service.session_message.PD.get_project_config", new_callable=AsyncMock, - side_effect=lock_results, - ) as mock_lock, + return_value=Result.resolve(MagicMock()), + ), patch( - "acontext_core.service.session_message.release_redis_lock", + "acontext_core.service.session_message._get_pending_session_message_ids", new_callable=AsyncMock, + side_effect=[ + Result.resolve([message_id]), + Result.resolve([message_id]), + Result.resolve([]), + ], ), patch( - "acontext_core.service.session_message.PD.get_project_config", + "acontext_core.service.session_message.MD.check_session_message_status", new_callable=AsyncMock, - return_value=Result.resolve(MagicMock()), + return_value=Result.resolve("pending"), ), patch( - "acontext_core.service.session_message.MC.process_session_pending_message", + "acontext_core.service.session_message.check_redis_lock_or_set", + new_callable=AsyncMock, + side_effect=[False, True], + ) as mock_lock, + patch( + "acontext_core.service.session_message.release_redis_lock", + new_callable=AsyncMock, + ) as mock_release, + patch( + "acontext_core.service.session_message.MC.process_inserted_message", new_callable=AsyncMock, return_value=Result.resolve(None), - ), - patch("acontext_core.service.session_message.DB_CLIENT") as mock_db, - patch("acontext_core.service.session_message.asyncio.sleep", new_callable=AsyncMock), + ) as mock_process, + patch( + "acontext_core.service.session_message.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep, ): - mock_db.get_session_context.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_db.get_session_context.return_value.__aexit__ = AsyncMock(return_value=False) - result = await flush_session_message_blocking(project_id, session_id) assert result.ok() - assert mock_lock.call_count == fail_count + 1 - + assert mock_lock.call_count == 2 + mock_sleep.assert_called_once() + mock_process.assert_called_once() + mock_release.assert_called_once_with( + project_id, f"session.message.insert.{session_id}.{message_id}" + ) -class TestFlushExhaustsRetries: - """Fix 2 — Flush exhausts retries.""" +class TestFlushBranchAwareRetries: @pytest.mark.asyncio - async def test_returns_reject_after_max_retries(self): - """Lock always fails — Result.reject() is returned after max_retries attempts.""" + async def test_returns_reject_after_max_retries_when_branch_locks_stay_busy(self): project_id = uuid.uuid4() session_id = uuid.uuid4() + message_id = uuid.uuid4() max_retries = 3 with ( + patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()), patch( - "acontext_core.service.session_message.check_redis_lock_or_set", + "acontext_core.service.session_message.PD.get_project_config", new_callable=AsyncMock, - return_value=False, # Always fail - ) as mock_lock, - patch("acontext_core.service.session_message.asyncio.sleep", new_callable=AsyncMock), + return_value=Result.resolve(MagicMock()), + ), patch( - "acontext_core.service.session_message.DEFAULT_CORE_CONFIG" - ) as mock_config, - ): - mock_config.session_message_flush_max_retries = max_retries - mock_config.session_message_session_lock_wait_seconds = 0 - - result = await flush_session_message_blocking(project_id, session_id) - - assert not result.ok() - assert "retries" in result.error.errmsg.lower() - assert mock_lock.call_count == max_retries - - @pytest.mark.asyncio - async def test_release_lock_not_called_on_exhaust(self): - """When retries exhaust, release_redis_lock should NOT be called - (lock was never acquired).""" - project_id = uuid.uuid4() - session_id = uuid.uuid4() - max_retries = 2 - - with ( + "acontext_core.service.session_message._get_pending_session_message_ids", + new_callable=AsyncMock, + side_effect=[Result.resolve([message_id])] * max_retries, + ), + patch( + "acontext_core.service.session_message.MD.check_session_message_status", + new_callable=AsyncMock, + return_value=Result.resolve("pending"), + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, return_value=False, - ), + ) as mock_lock, patch( "acontext_core.service.session_message.release_redis_lock", new_callable=AsyncMock, ) as mock_release, - patch("acontext_core.service.session_message.asyncio.sleep", new_callable=AsyncMock), + patch( + "acontext_core.service.session_message.asyncio.sleep", + new_callable=AsyncMock, + ), patch( "acontext_core.service.session_message.DEFAULT_CORE_CONFIG" ) as mock_config, @@ -152,4 +189,6 @@ async def test_release_lock_not_called_on_exhaust(self): result = await flush_session_message_blocking(project_id, session_id) assert not result.ok() + assert "retries" in result.error.errmsg.lower() + assert mock_lock.call_count == max_retries mock_release.assert_not_called() diff --git a/src/server/core/tests/service/test_process_session_pending_message.py b/src/server/core/tests/service/test_process_session_pending_message.py deleted file mode 100644 index bfdf5f1d1..000000000 --- a/src/server/core/tests/service/test_process_session_pending_message.py +++ /dev/null @@ -1,382 +0,0 @@ -""" -Tests for process_session_pending_message. - -Covers: -- Fix 1: CancelledError handling (BaseException catch + rollback) -- Fix 3: Error-Result rollback for fetch_messages_data_by_ids -- Fix 2: CoreConfig timeout defaults -- Edge cases: rollback failures, double-cancellation -""" - -import asyncio -import uuid -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from acontext_core.schema.result import Result -from acontext_core.schema.config import ProjectConfig, CoreConfig -from acontext_core.schema.session.task import TaskStatus -from acontext_core.service.controller.message import process_session_pending_message - - -MODULE = "acontext_core.service.controller.message" - -_PROJECT_ID = uuid.uuid4() -_SESSION_ID = uuid.uuid4() -_MSG_IDS = [uuid.uuid4(), uuid.uuid4()] - - -def _default_project_config(**overrides) -> ProjectConfig: - defaults = { - "project_session_message_buffer_max_turns": 16, - "project_session_message_buffer_max_overflow": 16, - } - defaults.update(overrides) - return ProjectConfig(**defaults) - - -def _mock_db(): - mock_db = MagicMock() - mock_db.get_session_context.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_db.get_session_context.return_value.__aexit__ = AsyncMock(return_value=False) - return mock_db - - -def _base_patches( - db_client=None, - get_message_ids_result=None, - update_status=None, - fetch_messages_result=None, - fetch_previous_result=None, - get_ls_result=None, - task_agent_result=None, - get_metrics_result=False, -): - """Return a dict of common patches for process_session_pending_message.""" - if db_client is None: - db_client = _mock_db() - if get_message_ids_result is None: - get_message_ids_result = Result.resolve(_MSG_IDS) - if update_status is None: - update_status = AsyncMock() - if fetch_messages_result is None: - msg = MagicMock() - msg.id = _MSG_IDS[0] - msg.role = "user" - msg.parts = [] - msg.task_id = None - msg.created_at = "2024-01-01" - fetch_messages_result = Result.resolve([msg]) - if fetch_previous_result is None: - fetch_previous_result = Result.resolve([]) - if get_ls_result is None: - get_ls_result = Result.resolve(None) - if task_agent_result is None: - task_agent_result = Result.resolve(None) - - return { - f"{MODULE}.DB_CLIENT": db_client, - f"{MODULE}.MD.get_message_ids": AsyncMock(return_value=get_message_ids_result), - f"{MODULE}.MD.update_message_status_to": update_status, - f"{MODULE}.MD.fetch_messages_data_by_ids": AsyncMock(return_value=fetch_messages_result), - f"{MODULE}.MD.fetch_previous_messages_by_datetime": AsyncMock(return_value=fetch_previous_result), - f"{MODULE}.LS.get_learning_space_for_session": AsyncMock(return_value=get_ls_result), - f"{MODULE}.AT.task_agent_curd": AsyncMock(return_value=task_agent_result), - f"{MODULE}.get_metrics": AsyncMock(return_value=get_metrics_result), - f"{MODULE}.get_wide_event": MagicMock(return_value={}), - } - - -# ============================================================================ -# Fix 1: CancelledError handling -# ============================================================================ - - -class TestCancelledErrorHandling: - @pytest.mark.asyncio - async def test_cancelled_error_after_running_rolls_back_to_failed(self): - """CancelledError after messages set to RUNNING -> rollback to FAILED, re-raised.""" - update_status = AsyncMock() - patches = _base_patches(update_status=update_status) - patches[f"{MODULE}.AT.task_agent_curd"] = AsyncMock( - side_effect=asyncio.CancelledError() - ) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - with pytest.raises(asyncio.CancelledError): - await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - - rollback_calls = [ - c for c in update_status.call_args_list - if c.args[1] == _MSG_IDS and c.args[2] == TaskStatus.FAILED - ] - assert len(rollback_calls) == 1 - finally: - for cm in cm_list: - cm.stop() - - @pytest.mark.asyncio - async def test_cancelled_error_before_pending_ids_set_reraises(self): - """CancelledError before pending_message_ids is set -> re-raised without rollback.""" - update_status = AsyncMock() - patches = _base_patches(update_status=update_status) - patches[f"{MODULE}.MD.get_message_ids"] = AsyncMock( - side_effect=asyncio.CancelledError() - ) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - with pytest.raises(asyncio.CancelledError): - await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - - rollback_calls = [ - c for c in update_status.call_args_list - if len(c.args) >= 3 and c.args[2] == TaskStatus.FAILED - ] - assert len(rollback_calls) == 0 - finally: - for cm in cm_list: - cm.stop() - - @pytest.mark.asyncio - async def test_regular_exception_after_running_rolls_back(self): - """Regular Exception after RUNNING -> messages rolled back to FAILED (regression).""" - update_status = AsyncMock() - patches = _base_patches(update_status=update_status) - patches[f"{MODULE}.AT.task_agent_curd"] = AsyncMock( - side_effect=RuntimeError("boom") - ) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - with pytest.raises(RuntimeError, match="boom"): - await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - - rollback_calls = [ - c for c in update_status.call_args_list - if c.args[1] == _MSG_IDS and c.args[2] == TaskStatus.FAILED - ] - assert len(rollback_calls) == 1 - finally: - for cm in cm_list: - cm.stop() - - @pytest.mark.asyncio - async def test_regular_exception_before_pending_ids_set_reraises(self): - """Regular Exception before pending_message_ids set -> re-raised without rollback.""" - update_status = AsyncMock() - patches = _base_patches(update_status=update_status) - patches[f"{MODULE}.MD.get_message_ids"] = AsyncMock( - side_effect=RuntimeError("early boom") - ) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - with pytest.raises(RuntimeError, match="early boom"): - await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - - rollback_calls = [ - c for c in update_status.call_args_list - if len(c.args) >= 3 and c.args[2] == TaskStatus.FAILED - ] - assert len(rollback_calls) == 0 - finally: - for cm in cm_list: - cm.stop() - - -# ============================================================================ -# Fix 3: Error-Result rollback for fetch_messages_data_by_ids -# ============================================================================ - - -class TestFetchMessagesErrorRollback: - @pytest.mark.asyncio - async def test_fetch_error_rolls_back_to_failed(self): - """fetch_messages_data_by_ids error Result -> rollback to FAILED, error returned.""" - update_status = AsyncMock() - error_result = Result.reject("S3 download failed") - patches = _base_patches( - update_status=update_status, - fetch_messages_result=error_result, - ) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - r = await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - - assert not r.ok() - rollback_calls = [ - c for c in update_status.call_args_list - if c.args[1] == _MSG_IDS and c.args[2] == TaskStatus.FAILED - ] - assert len(rollback_calls) == 1 - finally: - for cm in cm_list: - cm.stop() - - @pytest.mark.asyncio - async def test_fetch_success_does_not_rollback(self): - """fetch_messages_data_by_ids succeeds -> no rollback, processing continues.""" - update_status = AsyncMock() - patches = _base_patches(update_status=update_status) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - r = await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - - assert r.ok() - failed_calls = [ - c for c in update_status.call_args_list - if len(c.args) >= 3 and c.args[2] == TaskStatus.FAILED - ] - assert len(failed_calls) == 0 - success_calls = [ - c for c in update_status.call_args_list - if c.args[1] == _MSG_IDS and c.args[2] == TaskStatus.SUCCESS - ] - assert len(success_calls) == 1 - finally: - for cm in cm_list: - cm.stop() - - -# ============================================================================ -# Edge cases: rollback failures -# ============================================================================ - - -class TestRollbackFailures: - @pytest.mark.asyncio - async def test_rollback_db_failure_preserves_original_exception(self): - """update_message_status_to raises during rollback -> original CancelledError still re-raised.""" - call_count = 0 - - async def _update_side_effect(*args, **kwargs): - nonlocal call_count - call_count += 1 - if len(args) >= 3 and args[2] == TaskStatus.FAILED: - raise ConnectionError("DB is down") - - update_status = AsyncMock(side_effect=_update_side_effect) - patches = _base_patches(update_status=update_status) - patches[f"{MODULE}.AT.task_agent_curd"] = AsyncMock( - side_effect=asyncio.CancelledError() - ) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - with pytest.raises(asyncio.CancelledError): - await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - finally: - for cm in cm_list: - cm.stop() - - @pytest.mark.asyncio - async def test_rollback_cancelled_error_preserves_original(self): - """update_message_status_to raises CancelledError during rollback (double-cancel) -> original still re-raised.""" - async def _update_side_effect(*args, **kwargs): - if len(args) >= 3 and args[2] == TaskStatus.FAILED: - raise asyncio.CancelledError() - - update_status = AsyncMock(side_effect=_update_side_effect) - patches = _base_patches(update_status=update_status) - patches[f"{MODULE}.AT.task_agent_curd"] = AsyncMock( - side_effect=RuntimeError("original error") - ) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - with pytest.raises(RuntimeError, match="original error"): - await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - finally: - for cm in cm_list: - cm.stop() - - @pytest.mark.asyncio - async def test_fetch_error_rollback_failure_still_returns_error_result(self): - """update_message_status_to raises during Fix 3 rollback -> original error Result still returned.""" - async def _update_side_effect(*args, **kwargs): - if len(args) >= 3 and args[2] == TaskStatus.FAILED: - raise ConnectionError("DB is down") - - update_status = AsyncMock(side_effect=_update_side_effect) - error_result = Result.reject("S3 download failed") - patches = _base_patches( - update_status=update_status, - fetch_messages_result=error_result, - ) - - cm_list = [patch(k, v) for k, v in patches.items()] - for cm in cm_list: - cm.start() - try: - r = await process_session_pending_message( - _default_project_config(), _PROJECT_ID, _SESSION_ID - ) - - assert not r.ok() - assert "S3 download failed" in r.error.errmsg - finally: - for cm in cm_list: - cm.stop() - - -# ============================================================================ -# Fix 2: CoreConfig timeout defaults -# ============================================================================ - - -class TestCoreConfigDefaults: - def test_default_mq_handler_timeout_unchanged(self): - """mq_consumer_handler_timeout stays at 96.""" - config = CoreConfig(llm_api_key="test") - assert config.mq_consumer_handler_timeout == 96 - - def test_default_session_message_consumer_timeout(self): - """session_message_consumer_timeout defaults to 600.""" - config = CoreConfig(llm_api_key="test") - assert config.session_message_consumer_timeout == 600 - - def test_default_session_message_processing_timeout(self): - """session_message_processing_timeout_seconds defaults to 660.""" - config = CoreConfig(llm_api_key="test") - assert config.session_message_processing_timeout_seconds == 660 - - def test_lock_ttl_gte_consumer_timeout(self): - """Redis lock TTL must be >= consumer timeout.""" - config = CoreConfig(llm_api_key="test") - assert config.session_message_processing_timeout_seconds >= config.session_message_consumer_timeout From da8e7e5b50692f8d476bc79d357daca913e01be8 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Fri, 27 Mar 2026 18:07:35 +0530 Subject: [PATCH 05/19] write path fork implemented --- .../go/internal/modules/handler/session.go | 18 +- .../internal/modules/handler/session_test.go | 83 +++++++- .../api/go/internal/modules/repo/session.go | 25 ++- .../go/internal/modules/repo/session_test.go | 95 +++++++++ .../go/internal/modules/service/session.go | 15 ++ .../internal/modules/service/session_test.go | 200 ++++++++++++++++++ 6 files changed, 426 insertions(+), 10 deletions(-) diff --git a/src/server/api/go/internal/modules/handler/session.go b/src/server/api/go/internal/modules/handler/session.go index f8237ecd7..c72b80b3a 100644 --- a/src/server/api/go/internal/modules/handler/session.go +++ b/src/server/api/go/internal/modules/handler/session.go @@ -339,9 +339,10 @@ func (h *SessionHandler) GetConfigs(c *gin.Context) { } type StoreMessageReq struct { - Blob interface{} `form:"blob" json:"blob" binding:"required"` - Format string `form:"format" json:"format" binding:"omitempty,oneof=acontext openai anthropic gemini" example:"openai" enums:"acontext,openai,anthropic,gemini"` - Meta map[string]interface{} `form:"meta" json:"meta"` // Optional user-provided metadata for the message + Blob interface{} `form:"blob" json:"blob" binding:"required"` + Format string `form:"format" json:"format" binding:"omitempty,oneof=acontext openai anthropic gemini" example:"openai" enums:"acontext,openai,anthropic,gemini"` + ParentID string `form:"parent_id" json:"parent_id" example:"123e4567-e89b-12d3-a456-426614174000"` + Meta map[string]interface{} `form:"meta" json:"meta"` // Optional user-provided metadata for the message } // StoreMessage godoc @@ -457,6 +458,16 @@ func (h *SessionHandler) StoreMessage(c *gin.Context) { return } + var parentID *uuid.UUID + if strings.TrimSpace(req.ParentID) != "" { + parsedParentID, err := uuid.Parse(req.ParentID) + if err != nil { + c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid parent_id", err)) + return + } + parentID = &parsedParentID + } + // Store user-provided meta in __user_meta__ field for complete isolation from system fields if len(req.Meta) > 0 { if normalizedMeta == nil { @@ -468,6 +479,7 @@ func (h *SessionHandler) StoreMessage(c *gin.Context) { out, err := h.svc.StoreMessage(c.Request.Context(), service.StoreMessageInput{ ProjectID: project.ID, SessionID: sessionID, + ParentID: parentID, Role: normalizedRole, Parts: normalizedParts, Format: format, diff --git a/src/server/api/go/internal/modules/handler/session_test.go b/src/server/api/go/internal/modules/handler/session_test.go index 42b24b929..f09f5f2d4 100644 --- a/src/server/api/go/internal/modules/handler/session_test.go +++ b/src/server/api/go/internal/modules/handler/session_test.go @@ -641,6 +641,7 @@ func TestSessionHandler_GetConfigs(t *testing.T) { func TestSessionHandler_StoreMessage(t *testing.T) { projectID := uuid.New() sessionID := uuid.New() + jsonParentID := uuid.New() tests := []struct { name string @@ -672,7 +673,42 @@ func TestSessionHandler_StoreMessage(t *testing.T) { Role: model.RoleUser, } svc.On("StoreMessage", mock.Anything, mock.MatchedBy(func(in service.StoreMessageInput) bool { - return in.ProjectID == projectID && in.SessionID == sessionID && in.Role == model.RoleUser + return in.ProjectID == projectID && + in.SessionID == sessionID && + in.ParentID == nil && + in.Role == model.RoleUser + })).Return(expectedMessage, nil) + }, + expectedStatus: http.StatusCreated, + }, + { + name: "acontext format - successful text message with parent_id", + sessionIDParam: sessionID.String(), + requestBody: map[string]interface{}{ + "format": "acontext", + "parent_id": jsonParentID.String(), + "blob": map[string]interface{}{ + "role": "user", + "parts": []map[string]interface{}{ + { + "type": "text", + "text": "fork here", + }, + }, + }, + }, + setup: func(svc *MockSessionService) { + expectedMessage := &model.Message{ + ID: uuid.New(), + SessionID: sessionID, + Role: model.RoleUser, + } + svc.On("StoreMessage", mock.Anything, mock.MatchedBy(func(in service.StoreMessageInput) bool { + return in.ProjectID == projectID && + in.SessionID == sessionID && + in.ParentID != nil && + *in.ParentID == jsonParentID && + in.Role == model.RoleUser })).Return(expectedMessage, nil) }, expectedStatus: http.StatusCreated, @@ -762,6 +798,20 @@ func TestSessionHandler_StoreMessage(t *testing.T) { }, expectedStatus: http.StatusCreated, }, + { + name: "invalid parent id", + sessionIDParam: sessionID.String(), + requestBody: map[string]interface{}{ + "format": "openai", + "parent_id": "not-a-uuid", + "blob": map[string]interface{}{ + "role": "user", + "content": "Hello from OpenAI format!", + }, + }, + setup: func(svc *MockSessionService) {}, + expectedStatus: http.StatusBadRequest, + }, { name: "openai format - multipart content with text and image", sessionIDParam: sessionID.String(), @@ -2381,6 +2431,7 @@ func TestSessionHandler_GetMessages(t *testing.T) { func TestSessionHandler_StoreMessage_Multipart(t *testing.T) { projectID := uuid.New() sessionID := uuid.New() + multipartParentID := uuid.New() tests := []struct { name string @@ -2422,7 +2473,35 @@ func TestSessionHandler_StoreMessage_Multipart(t *testing.T) { Role: model.RoleUser, } svc.On("StoreMessage", mock.Anything, mock.MatchedBy(func(in service.StoreMessageInput) bool { - return in.ProjectID == projectID && in.SessionID == sessionID && in.Role == model.RoleUser && len(in.Parts) > 0 + return in.ProjectID == projectID && in.SessionID == sessionID && in.ParentID == nil && in.Role == model.RoleUser && len(in.Parts) > 0 + })).Return(expectedMessage, nil) + }, + expectedStatus: http.StatusCreated, + }, + { + name: "successful multipart message with parent_id", + sessionIDParam: sessionID.String(), + payload: `{ + "format": "openai", + "parent_id": "` + multipartParentID.String() + `", + "blob": { + "role": "user", + "content": "fork from multipart" + } + }`, + files: map[string]string{}, + setup: func(svc *MockSessionService) { + expectedMessage := &model.Message{ + ID: uuid.New(), + SessionID: sessionID, + Role: model.RoleUser, + } + svc.On("StoreMessage", mock.Anything, mock.MatchedBy(func(in service.StoreMessageInput) bool { + return in.ProjectID == projectID && + in.SessionID == sessionID && + in.ParentID != nil && + *in.ParentID == multipartParentID && + in.Role == model.RoleUser })).Return(expectedMessage, nil) }, expectedStatus: http.StatusCreated, diff --git a/src/server/api/go/internal/modules/repo/session.go b/src/server/api/go/internal/modules/repo/session.go index 852ac84ea..a518fe053 100644 --- a/src/server/api/go/internal/modules/repo/session.go +++ b/src/server/api/go/internal/modules/repo/session.go @@ -37,6 +37,7 @@ type SessionRepo interface { GetObservingStatus(ctx context.Context, sessionID string) (*model.MessageObservingStatus, error) PopGeminiCallIDAndName(ctx context.Context, sessionID uuid.UUID) (string, string, error) GetMessageByID(ctx context.Context, sessionID uuid.UUID, messageID uuid.UUID) (*model.Message, error) + GetMessageByIDAnySession(ctx context.Context, messageID uuid.UUID) (*model.Message, error) UpdateMessageMeta(ctx context.Context, messageID uuid.UUID, meta datatypes.JSONType[map[string]interface{}]) error CopySession(ctx context.Context, sessionID uuid.UUID, userKEK []byte) (*CopySessionResult, error) HasUnfinishedMessages(ctx context.Context, sessionID uuid.UUID) (bool, error) @@ -193,11 +194,13 @@ func (r *sessionRepo) ListWithCursor(ctx context.Context, projectID uuid.UUID, u func (r *sessionRepo) CreateMessageWithAssets(ctx context.Context, msg *model.Message) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // First get the message parent id in session - parent := model.Message{} - if err := tx.Select("id").Where(&model.Message{SessionID: msg.SessionID}).Order("created_at desc").Limit(1).Find(&parent).Error; err == nil { - if parent.ID != uuid.Nil { - msg.ParentID = &parent.ID + if msg.ParentID == nil { + // Default linear append: parent is the latest message in the session. + parent := model.Message{} + if err := tx.Select("id").Where(&model.Message{SessionID: msg.SessionID}).Order("created_at desc").Limit(1).Find(&parent).Error; err == nil { + if parent.ID != uuid.Nil { + msg.ParentID = &parent.ID + } } } @@ -429,6 +432,18 @@ func (r *sessionRepo) GetMessageByID(ctx context.Context, sessionID uuid.UUID, m return &msg, nil } +// GetMessageByIDAnySession retrieves a message by ID without applying a session filter. +func (r *sessionRepo) GetMessageByIDAnySession(ctx context.Context, messageID uuid.UUID) (*model.Message, error) { + var msg model.Message + err := r.db.WithContext(ctx). + Where("id = ?", messageID). + First(&msg).Error + if err != nil { + return nil, err + } + return &msg, nil +} + // UpdateMessageMeta updates the meta field of a message. func (r *sessionRepo) UpdateMessageMeta(ctx context.Context, messageID uuid.UUID, meta datatypes.JSONType[map[string]interface{}]) error { return r.db.WithContext(ctx). diff --git a/src/server/api/go/internal/modules/repo/session_test.go b/src/server/api/go/internal/modules/repo/session_test.go index 5b4c72cac..4e1146ffc 100644 --- a/src/server/api/go/internal/modules/repo/session_test.go +++ b/src/server/api/go/internal/modules/repo/session_test.go @@ -43,6 +43,101 @@ func cleanupSessionTestDB(t *testing.T, db *gorm.DB, projectID uuid.UUID) { db.Exec("DELETE FROM projects WHERE id = ?", projectID) } +func TestSessionRepo_CreateMessageWithAssets_ParentSelection(t *testing.T) { + db := setupSessionTestDB(t) + if db == nil { + return + } + + require.NoError(t, db.AutoMigrate(&model.Message{})) + + logger, _ := zap.NewDevelopment() + repo := NewSessionRepo(db, nil, nil, logger) + ctx := context.Background() + + project := &model.Project{ + ID: uuid.New(), + SecretKeyHMAC: "test_hmac_create_message", + SecretKeyHashPHC: "test_hash_create_message", + } + require.NoError(t, db.Create(project).Error) + defer cleanupSessionTestDB(t, db, project.ID) + + t.Run("first message keeps nil parent when parent id is omitted", func(t *testing.T) { + session := &model.Session{ + ID: uuid.New(), + ProjectID: project.ID, + } + require.NoError(t, db.Create(session).Error) + + msg := &model.Message{ + ID: uuid.New(), + SessionID: session.ID, + Role: model.RoleUser, + PartsAssetMeta: datatypes.NewJSONType(model.Asset{SHA256: "sha-first", S3Key: "parts/first.json"}), + } + + require.NoError(t, repo.CreateMessageWithAssets(ctx, msg)) + assert.Nil(t, msg.ParentID) + }) + + t.Run("message preserves explicit parent id", func(t *testing.T) { + session := &model.Session{ + ID: uuid.New(), + ProjectID: project.ID, + } + require.NoError(t, db.Create(session).Error) + + root := &model.Message{ + ID: uuid.New(), + SessionID: session.ID, + Role: model.RoleUser, + PartsAssetMeta: datatypes.NewJSONType(model.Asset{SHA256: "sha-root-explicit", S3Key: "parts/root-explicit.json"}), + } + require.NoError(t, db.Create(root).Error) + + explicitParentID := root.ID + msg := &model.Message{ + ID: uuid.New(), + SessionID: session.ID, + ParentID: &explicitParentID, + Role: model.RoleAssistant, + PartsAssetMeta: datatypes.NewJSONType(model.Asset{SHA256: "sha-explicit", S3Key: "parts/explicit.json"}), + } + + require.NoError(t, repo.CreateMessageWithAssets(ctx, msg)) + require.NotNil(t, msg.ParentID) + assert.Equal(t, explicitParentID, *msg.ParentID) + }) + + t.Run("message auto-links to latest session message when parent id is nil", func(t *testing.T) { + session := &model.Session{ + ID: uuid.New(), + ProjectID: project.ID, + } + require.NoError(t, db.Create(session).Error) + + parent := &model.Message{ + ID: uuid.New(), + SessionID: session.ID, + Role: model.RoleUser, + PartsAssetMeta: datatypes.NewJSONType(model.Asset{SHA256: "sha-auto-parent", S3Key: "parts/auto-parent.json"}), + } + require.NoError(t, db.Create(parent).Error) + + msg := &model.Message{ + ID: uuid.New(), + SessionID: session.ID, + Role: model.RoleAssistant, + PartsAssetMeta: datatypes.NewJSONType(model.Asset{SHA256: "sha-auto-child", S3Key: "parts/auto-child.json"}), + } + + require.NoError(t, repo.CreateMessageWithAssets(ctx, msg)) + require.NotNil(t, msg.ParentID) + assert.Equal(t, parent.ID, *msg.ParentID) + }) +} + // TestSessionRepo_GetDisableTaskTracking tests the GetDisableTaskTracking method func TestSessionRepo_GetDisableTaskTracking(t *testing.T) { db := setupSessionTestDB(t) diff --git a/src/server/api/go/internal/modules/service/session.go b/src/server/api/go/internal/modules/service/session.go index d1fa0a33f..0cd61e237 100644 --- a/src/server/api/go/internal/modules/service/session.go +++ b/src/server/api/go/internal/modules/service/session.go @@ -170,6 +170,7 @@ func (s *sessionService) List(ctx context.Context, in ListSessionsInput) (*ListS type StoreMessageInput struct { ProjectID uuid.UUID SessionID uuid.UUID + ParentID *uuid.UUID Role string Parts []PartIn Format model.MessageFormat // Message format (acontext, openai, anthropic, gemini) @@ -306,6 +307,19 @@ func (s *sessionService) StoreMessage(ctx context.Context, in StoreMessageInput) return nil, fmt.Errorf("session does not belong to project") } + if in.ParentID != nil { + parent, err := s.sessionRepo.GetMessageByIDAnySession(ctx, *in.ParentID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("parent message not found") + } + return nil, fmt.Errorf("failed to get parent message: %w", err) + } + if parent.SessionID != in.SessionID { + return nil, fmt.Errorf("parent message does not belong to session") + } + } + parts := make([]model.Part, 0, len(in.Parts)) var uploadedAssets []model.Asset var pendingUploads []*blob.PreparedUpload @@ -397,6 +411,7 @@ func (s *sessionService) StoreMessage(ctx context.Context, in StoreMessageInput) msg := model.Message{ SessionID: in.SessionID, + ParentID: in.ParentID, Role: in.Role, Meta: datatypes.NewJSONType(messageMeta), // Store message-level metadata PartsAssetMeta: datatypes.NewJSONType(partsAsset), diff --git a/src/server/api/go/internal/modules/service/session_test.go b/src/server/api/go/internal/modules/service/session_test.go index b79e5175f..e36d8c68a 100644 --- a/src/server/api/go/internal/modules/service/session_test.go +++ b/src/server/api/go/internal/modules/service/session_test.go @@ -9,8 +9,11 @@ import ( "time" "github.com/alicebob/miniredis/v2" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/google/uuid" "github.com/memodb-io/Acontext/internal/config" + "github.com/memodb-io/Acontext/internal/infra/blob" "github.com/memodb-io/Acontext/internal/modules/model" "github.com/memodb-io/Acontext/internal/modules/repo" "github.com/redis/go-redis/v9" @@ -18,6 +21,7 @@ import ( "github.com/stretchr/testify/mock" "go.uber.org/zap" "gorm.io/datatypes" + "gorm.io/gorm" ) // MockSessionRepo is a mock implementation of SessionRepo @@ -103,6 +107,14 @@ func (m *MockSessionRepo) GetMessageByID(ctx context.Context, sessionID uuid.UUI return args.Get(0).(*model.Message), args.Error(1) } +func (m *MockSessionRepo) GetMessageByIDAnySession(ctx context.Context, messageID uuid.UUID) (*model.Message, error) { + args := m.Called(ctx, messageID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Message), args.Error(1) +} + func (m *MockSessionRepo) UpdateMessageMeta(ctx context.Context, messageID uuid.UUID, meta datatypes.JSONType[map[string]interface{}]) error { args := m.Called(ctx, messageID, meta) return args.Error(0) @@ -182,6 +194,35 @@ type MockBlobService struct { mock.Mock } +type stubUploadAPIClient struct{} + +func (stubUploadAPIClient) PutObject(context.Context, *awss3.PutObjectInput, ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) { + return &awss3.PutObjectOutput{}, nil +} + +func (stubUploadAPIClient) UploadPart(context.Context, *awss3.UploadPartInput, ...func(*awss3.Options)) (*awss3.UploadPartOutput, error) { + return &awss3.UploadPartOutput{}, nil +} + +func (stubUploadAPIClient) CreateMultipartUpload(context.Context, *awss3.CreateMultipartUploadInput, ...func(*awss3.Options)) (*awss3.CreateMultipartUploadOutput, error) { + return &awss3.CreateMultipartUploadOutput{}, nil +} + +func (stubUploadAPIClient) CompleteMultipartUpload(context.Context, *awss3.CompleteMultipartUploadInput, ...func(*awss3.Options)) (*awss3.CompleteMultipartUploadOutput, error) { + return &awss3.CompleteMultipartUploadOutput{}, nil +} + +func (stubUploadAPIClient) AbortMultipartUpload(context.Context, *awss3.AbortMultipartUploadInput, ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) { + return &awss3.AbortMultipartUploadOutput{}, nil +} + +func newTestSessionS3Deps() *blob.S3Deps { + return &blob.S3Deps{ + Bucket: "test-bucket", + Uploader: manager.NewUploader(stubUploadAPIClient{}), + } +} + func (m *MockBlobService) UploadJSON(ctx context.Context, prefix string, data interface{}) (*model.Asset, error) { args := m.Called(ctx, prefix, data) if args.Get(0) == nil { @@ -1144,6 +1185,165 @@ func TestSessionService_StoreMessage_GeminiFunctionResponse(t *testing.T) { } } +func TestSessionService_StoreMessage_ParentID(t *testing.T) { + ctx := context.Background() + projectID := uuid.New() + sessionID := uuid.New() + parentID := uuid.New() + otherSessionID := uuid.New() + + newService := func(repo *MockSessionRepo, assetBuffer *MockAssetRefBuffer) SessionService { + logger := zap.NewNop() + cfg := &config.Config{ + RabbitMQ: config.MQCfg{ + ExchangeName: config.MQExchangeName{ + SessionMessage: "session.message", + }, + RoutingKey: config.MQRoutingKey{ + SessionMessageInsert: "session.message.insert", + }, + }, + } + return NewSessionService( + repo, + nil, + &MockAssetReferenceRepo{}, + assetBuffer, + logger, + newTestSessionS3Deps(), + nil, + cfg, + nil, + nil, + ) + } + + baseInput := StoreMessageInput{ + ProjectID: projectID, + SessionID: sessionID, + Role: model.RoleUser, + Parts: []PartIn{ + {Type: model.PartTypeText, Text: "fork me"}, + }, + Format: model.FormatAcontext, + MessageMeta: map[string]interface{}{ + model.MsgMetaSourceFormat: "acontext", + }, + } + + t.Run("returns error when parent message does not exist", func(t *testing.T) { + repo := &MockSessionRepo{} + assetBuffer := &MockAssetRefBuffer{} + + repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { + return s.ID == sessionID + })).Return(&model.Session{ID: sessionID, ProjectID: projectID}, nil) + repo.On("GetMessageByIDAnySession", ctx, parentID).Return(nil, gorm.ErrRecordNotFound) + + service := newService(repo, assetBuffer) + input := baseInput + input.ParentID = &parentID + + result, err := service.StoreMessage(ctx, input) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "parent message not found") + + repo.AssertExpectations(t) + assetBuffer.AssertExpectations(t) + }) + + t.Run("returns error when parent message belongs to another session", func(t *testing.T) { + repo := &MockSessionRepo{} + assetBuffer := &MockAssetRefBuffer{} + + repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { + return s.ID == sessionID + })).Return(&model.Session{ID: sessionID, ProjectID: projectID}, nil) + repo.On("GetMessageByIDAnySession", ctx, parentID).Return(&model.Message{ + ID: parentID, + SessionID: otherSessionID, + }, nil) + + service := newService(repo, assetBuffer) + input := baseInput + input.ParentID = &parentID + + result, err := service.StoreMessage(ctx, input) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "parent message does not belong to session") + + repo.AssertExpectations(t) + assetBuffer.AssertExpectations(t) + }) + + t.Run("passes explicit parent id through to repo create", func(t *testing.T) { + repo := &MockSessionRepo{} + assetBuffer := &MockAssetRefBuffer{} + + repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { + return s.ID == sessionID + })).Return(&model.Session{ID: sessionID, ProjectID: projectID}, nil) + repo.On("GetMessageByIDAnySession", ctx, parentID).Return(&model.Message{ + ID: parentID, + SessionID: sessionID, + }, nil) + repo.On("CreateMessageWithAssets", ctx, mock.MatchedBy(func(msg *model.Message) bool { + return msg.SessionID == sessionID && + msg.ParentID != nil && + *msg.ParentID == parentID && + msg.Role == model.RoleUser + })).Run(func(args mock.Arguments) { + msg := args.Get(1).(*model.Message) + msg.ID = uuid.New() + }).Return(nil) + repo.On("GetDisableTaskTracking", ctx, sessionID).Return(false, nil) + assetBuffer.On("Enqueue", ctx, projectID, mock.AnythingOfType("[]model.Asset")).Return(nil) + + service := newService(repo, assetBuffer) + input := baseInput + input.ParentID = &parentID + + result, err := service.StoreMessage(ctx, input) + assert.NoError(t, err) + assert.NotNil(t, result) + if assert.NotNil(t, result.ParentID) { + assert.Equal(t, parentID, *result.ParentID) + } + + repo.AssertExpectations(t) + assetBuffer.AssertExpectations(t) + }) + + t.Run("leaves parent unset when parent id is omitted", func(t *testing.T) { + repo := &MockSessionRepo{} + assetBuffer := &MockAssetRefBuffer{} + + repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { + return s.ID == sessionID + })).Return(&model.Session{ID: sessionID, ProjectID: projectID}, nil) + repo.On("CreateMessageWithAssets", ctx, mock.MatchedBy(func(msg *model.Message) bool { + return msg.SessionID == sessionID && msg.ParentID == nil + })).Run(func(args mock.Arguments) { + msg := args.Get(1).(*model.Message) + msg.ID = uuid.New() + }).Return(nil) + repo.On("GetDisableTaskTracking", ctx, sessionID).Return(false, nil) + assetBuffer.On("Enqueue", ctx, projectID, mock.AnythingOfType("[]model.Asset")).Return(nil) + + service := newService(repo, assetBuffer) + + result, err := service.StoreMessage(ctx, baseInput) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Nil(t, result.ParentID) + + repo.AssertExpectations(t) + assetBuffer.AssertExpectations(t) + }) +} + func TestSessionService_GetMessages(t *testing.T) { ctx := context.Background() sessionID := uuid.New() From 86a57c06eb7754dc7d07563a52509d0e6d186656 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Fri, 27 Mar 2026 22:33:25 +0530 Subject: [PATCH 06/19] fixes --- .../go/internal/modules/handler/session.go | 26 +++++++ .../internal/modules/handler/session_test.go | 50 ++++++++++++ .../api/go/internal/modules/repo/session.go | 31 ++++++++ .../go/internal/modules/repo/session_test.go | 77 +++++++++++++++++++ .../go/internal/modules/service/session.go | 42 ++++++---- .../internal/modules/service/session_test.go | 47 +++++++++++ 6 files changed, 259 insertions(+), 14 deletions(-) diff --git a/src/server/api/go/internal/modules/handler/session.go b/src/server/api/go/internal/modules/handler/session.go index c72b80b3a..638765bb1 100644 --- a/src/server/api/go/internal/modules/handler/session.go +++ b/src/server/api/go/internal/modules/handler/session.go @@ -502,6 +502,7 @@ func (h *SessionHandler) StoreMessage(c *gin.Context) { type GetMessagesReq struct { Limit *int `form:"limit" json:"limit" binding:"omitempty,min=0,max=200" example:"20"` Cursor string `form:"cursor" json:"cursor" example:"cHJvdGVjdGVkIHZlcnNpb24gdG8gYmUgZXhjbHVkZWQgaW4gcGFyc2luZyB0aGUgY3Vyc29y"` + BranchMessageID string `form:"branch_message_id" json:"branch_message_id" example:""` WithAssetPublicURL bool `form:"with_asset_public_url,default=true" json:"with_asset_public_url" example:"true"` WithEvents bool `form:"with_events,default=false" json:"with_events" example:"false"` Format string `form:"format,default=openai" json:"format" binding:"omitempty,oneof=acontext openai anthropic gemini" example:"openai" enums:"acontext,openai,anthropic,gemini"` @@ -520,6 +521,7 @@ type GetMessagesReq struct { // @Param session_id path string true "Session ID" format(uuid) // @Param limit query integer false "Limit of messages to return. Max 200. If limit is 0 or not provided, all messages will be returned. \n\nWARNING!\n Use `limit` only for read-only/display purposes (pagination, viewing). Do NOT use `limit` to truncate messages before sending to LLM as it may cause tool-call and tool-result unpairing issues. Instead, use the `token_limit` edit strategy in `edit_strategies` parameter to safely manage message context size." // @Param cursor query string false "Cursor for pagination. Use the cursor from the previous response to get the next page." +// @Param branch_message_id query string false "Return only the root-to-target branch for this message ID. Cannot be combined with limit, cursor, or time_desc." format(uuid) // @Param with_asset_public_url query boolean false "Whether to return asset public url, default is true" example(true) // @Param with_events query boolean false "Whether to include session events in the response, default is false" example(false) // @Param format query string false "Format to convert messages to: acontext (original), openai (default), anthropic, gemini." enums(acontext,openai,anthropic,gemini) @@ -555,6 +557,29 @@ func (h *SessionHandler) GetMessages(c *gin.Context) { limit = *req.Limit } + var branchMessageID *uuid.UUID + if req.BranchMessageID != "" { + if req.Limit != nil { + c.JSON(http.StatusBadRequest, serializer.ParamErr("branch_message_id cannot be combined with limit", nil)) + return + } + if _, ok := c.GetQuery("cursor"); ok { + c.JSON(http.StatusBadRequest, serializer.ParamErr("branch_message_id cannot be combined with cursor", nil)) + return + } + if _, ok := c.GetQuery("time_desc"); ok { + c.JSON(http.StatusBadRequest, serializer.ParamErr("branch_message_id cannot be combined with time_desc", nil)) + return + } + + parsedBranchMessageID, err := uuid.Parse(req.BranchMessageID) + if err != nil { + c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid branch_message_id", err)) + return + } + branchMessageID = &parsedBranchMessageID + } + // Parse edit strategies if provided var editStrategies []editor.StrategyConfig if req.EditStrategies != "" { @@ -567,6 +592,7 @@ func (h *SessionHandler) GetMessages(c *gin.Context) { out, err := h.svc.GetMessages(c.Request.Context(), service.GetMessagesInput{ ProjectID: project.ID, SessionID: sessionID, + BranchMessageID: branchMessageID, Limit: limit, Cursor: req.Cursor, WithAssetPublicURL: req.WithAssetPublicURL, diff --git a/src/server/api/go/internal/modules/handler/session_test.go b/src/server/api/go/internal/modules/handler/session_test.go index f09f5f2d4..265b12efa 100644 --- a/src/server/api/go/internal/modules/handler/session_test.go +++ b/src/server/api/go/internal/modules/handler/session_test.go @@ -2042,6 +2042,7 @@ func TestSessionHandler_StoreMessage(t *testing.T) { func TestSessionHandler_GetMessages(t *testing.T) { projectID := uuid.New() sessionID := uuid.New() + branchMessageID := uuid.New() tests := []struct { name string @@ -2108,6 +2109,55 @@ func TestSessionHandler_GetMessages(t *testing.T) { }, expectedStatus: http.StatusBadRequest, }, + { + name: "branch_message_id retrieves branch messages", + sessionIDParam: sessionID.String(), + queryParams: "?branch_message_id=" + branchMessageID.String(), + setup: func(svc *MockSessionService) { + expectedOutput := &service.GetMessagesOutput{ + Items: []model.Message{ + { + ID: branchMessageID, + SessionID: sessionID, + Role: model.RoleUser, + }, + }, + HasMore: false, + } + svc.On("GetMessages", mock.Anything, mock.MatchedBy(func(in service.GetMessagesInput) bool { + return in.SessionID == sessionID && in.BranchMessageID != nil && *in.BranchMessageID == branchMessageID && in.Limit == 0 + })).Return(expectedOutput, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid branch_message_id", + sessionIDParam: sessionID.String(), + queryParams: "?branch_message_id=not-a-uuid", + setup: func(svc *MockSessionService) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "branch_message_id rejects limit", + sessionIDParam: sessionID.String(), + queryParams: "?branch_message_id=" + branchMessageID.String() + "&limit=20", + setup: func(svc *MockSessionService) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "branch_message_id rejects cursor", + sessionIDParam: sessionID.String(), + queryParams: "?branch_message_id=" + branchMessageID.String() + "&cursor=abc", + setup: func(svc *MockSessionService) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "branch_message_id rejects time_desc", + sessionIDParam: sessionID.String(), + queryParams: "?branch_message_id=" + branchMessageID.String() + "&time_desc=false", + setup: func(svc *MockSessionService) {}, + expectedStatus: http.StatusBadRequest, + }, // Additional edge cases and error scenarios for GetMessages { diff --git a/src/server/api/go/internal/modules/repo/session.go b/src/server/api/go/internal/modules/repo/session.go index a518fe053..5aea0b0cb 100644 --- a/src/server/api/go/internal/modules/repo/session.go +++ b/src/server/api/go/internal/modules/repo/session.go @@ -34,6 +34,7 @@ type SessionRepo interface { CreateMessageWithAssets(ctx context.Context, msg *model.Message) error ListBySessionWithCursor(ctx context.Context, sessionID uuid.UUID, afterCreatedAt time.Time, afterID uuid.UUID, limit int, timeDesc bool) ([]model.Message, error) ListAllMessagesBySession(ctx context.Context, sessionID uuid.UUID) ([]model.Message, error) + ListMessageBranchPath(ctx context.Context, sessionID uuid.UUID, messageID uuid.UUID) ([]model.Message, error) GetObservingStatus(ctx context.Context, sessionID string) (*model.MessageObservingStatus, error) PopGeminiCallIDAndName(ctx context.Context, sessionID uuid.UUID) (string, string, error) GetMessageByID(ctx context.Context, sessionID uuid.UUID, messageID uuid.UUID) (*model.Message, error) @@ -245,6 +246,36 @@ func (r *sessionRepo) ListAllMessagesBySession(ctx context.Context, sessionID uu return messages, err } +func (r *sessionRepo) ListMessageBranchPath(ctx context.Context, sessionID uuid.UUID, messageID uuid.UUID) ([]model.Message, error) { + var messages []model.Message + err := r.db.WithContext(ctx).Raw(` + WITH RECURSIVE message_path AS ( + SELECT id, parent_id, session_id, 0 AS depth + FROM messages + WHERE id = ? AND session_id = ? + + UNION ALL + + SELECT parent.id, parent.parent_id, parent.session_id, child.depth + 1 AS depth + FROM messages AS parent + JOIN message_path AS child + ON parent.id = child.parent_id + WHERE parent.session_id = ? + ) + SELECT m.* + FROM message_path AS mp + JOIN messages AS m ON m.id = mp.id + ORDER BY mp.depth DESC, m.id ASC + `, messageID, sessionID, sessionID).Scan(&messages).Error + if err != nil { + return nil, err + } + if len(messages) == 0 { + return nil, gorm.ErrRecordNotFound + } + return messages, nil +} + // GetObservingStatus returns the count of messages by status for a session // Maps session_task_process_status values to observing status func (r *sessionRepo) GetObservingStatus( diff --git a/src/server/api/go/internal/modules/repo/session_test.go b/src/server/api/go/internal/modules/repo/session_test.go index 4e1146ffc..252bda76b 100644 --- a/src/server/api/go/internal/modules/repo/session_test.go +++ b/src/server/api/go/internal/modules/repo/session_test.go @@ -529,6 +529,83 @@ func (m *MockAssetReferenceRepoForCopy) ListS3KeysByProject(ctx context.Context, return nil, nil } +func TestSessionRepo_ListMessageBranchPath(t *testing.T) { + db := setupSessionTestDB(t) + if db == nil { + return + } + + require.NoError(t, db.AutoMigrate(&model.Message{})) + + logger, _ := zap.NewDevelopment() + repo := NewSessionRepo(db, nil, nil, logger) + ctx := context.Background() + + project := &model.Project{ + ID: uuid.New(), + SecretKeyHMAC: "test_hmac_branch_path", + SecretKeyHashPHC: "test_hash_branch_path", + } + require.NoError(t, db.Create(project).Error) + defer cleanupSessionTestDB(t, db, project.ID) + + sessionA := &model.Session{ + ID: uuid.New(), + ProjectID: project.ID, + } + require.NoError(t, db.Create(sessionA).Error) + + sessionB := &model.Session{ + ID: uuid.New(), + ProjectID: project.ID, + } + require.NoError(t, db.Create(sessionB).Error) + + root := &model.Message{ + ID: uuid.New(), + SessionID: sessionA.ID, + Role: model.RoleUser, + PartsAssetMeta: datatypes.NewJSONType(model.Asset{SHA256: "sha-branch-root", S3Key: "parts/branch-root.json"}), + } + require.NoError(t, db.Create(root).Error) + + child := &model.Message{ + ID: uuid.New(), + SessionID: sessionA.ID, + ParentID: &root.ID, + Role: model.RoleAssistant, + PartsAssetMeta: datatypes.NewJSONType(model.Asset{SHA256: "sha-branch-child", S3Key: "parts/branch-child.json"}), + } + require.NoError(t, db.Create(child).Error) + + leaf := &model.Message{ + ID: uuid.New(), + SessionID: sessionA.ID, + ParentID: &child.ID, + Role: model.RoleUser, + PartsAssetMeta: datatypes.NewJSONType(model.Asset{SHA256: "sha-branch-leaf", S3Key: "parts/branch-leaf.json"}), + } + require.NoError(t, db.Create(leaf).Error) + + t.Run("returns root-to-target branch order", func(t *testing.T) { + messages, err := repo.ListMessageBranchPath(ctx, sessionA.ID, leaf.ID) + require.NoError(t, err) + require.Len(t, messages, 3) + assert.Equal(t, []uuid.UUID{root.ID, child.ID, leaf.ID}, []uuid.UUID{ + messages[0].ID, + messages[1].ID, + messages[2].ID, + }) + }) + + t.Run("returns not found for wrong session", func(t *testing.T) { + messages, err := repo.ListMessageBranchPath(ctx, sessionB.ID, leaf.ID) + require.Error(t, err) + assert.Nil(t, messages) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) +} + // TestSessionRepo_CopySession tests the CopySession method with comprehensive scenarios func TestSessionRepo_CopySession(t *testing.T) { db := setupSessionTestDB(t) diff --git a/src/server/api/go/internal/modules/service/session.go b/src/server/api/go/internal/modules/service/session.go index 0cd61e237..75cb94798 100644 --- a/src/server/api/go/internal/modules/service/session.go +++ b/src/server/api/go/internal/modules/service/session.go @@ -453,6 +453,7 @@ func (s *sessionService) StoreMessage(ctx context.Context, in StoreMessageInput) type GetMessagesInput struct { ProjectID uuid.UUID `json:"project_id"` SessionID uuid.UUID `json:"session_id"` + BranchMessageID *uuid.UUID `json:"branch_message_id,omitempty"` Limit int `json:"limit"` Cursor string `json:"cursor"` WithAssetPublicURL bool `json:"with_public_url"` @@ -490,28 +491,41 @@ func (s *sessionService) GetMessages(ctx context.Context, in GetMessagesInput) ( var msgs []model.Message - // Retrieve messages based on limit - if in.Limit <= 0 { - // If limit <= 0, retrieve all messages - msgs, err = s.sessionRepo.ListAllMessagesBySession(ctx, in.SessionID) + if in.BranchMessageID != nil { + if in.Limit > 0 || in.Cursor != "" || in.TimeDesc { + return nil, fmt.Errorf("branch_message_id cannot be combined with limit, cursor, or time_desc") + } + msgs, err = s.sessionRepo.ListMessageBranchPath(ctx, in.SessionID, *in.BranchMessageID) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("branch message not found") + } return nil, err } } else { - // Parse cursor (createdAt, id); an empty cursor indicates starting from the latest - var afterT time.Time - var afterID uuid.UUID - if in.Cursor != "" { - afterT, afterID, err = paging.DecodeCursor(in.Cursor) + // Retrieve messages based on limit + if in.Limit <= 0 { + // If limit <= 0, retrieve all messages + msgs, err = s.sessionRepo.ListAllMessagesBySession(ctx, in.SessionID) if err != nil { return nil, err } - } + } else { + // Parse cursor (createdAt, id); an empty cursor indicates starting from the latest + var afterT time.Time + var afterID uuid.UUID + if in.Cursor != "" { + afterT, afterID, err = paging.DecodeCursor(in.Cursor) + if err != nil { + return nil, err + } + } - // Query limit+1 is used to determine has_more - msgs, err = s.sessionRepo.ListBySessionWithCursor(ctx, in.SessionID, afterT, afterID, in.Limit+1, in.TimeDesc) - if err != nil { - return nil, err + // Query limit+1 is used to determine has_more + msgs, err = s.sessionRepo.ListBySessionWithCursor(ctx, in.SessionID, afterT, afterID, in.Limit+1, in.TimeDesc) + if err != nil { + return nil, err + } } } diff --git a/src/server/api/go/internal/modules/service/session_test.go b/src/server/api/go/internal/modules/service/session_test.go index e36d8c68a..6ef2ec7ff 100644 --- a/src/server/api/go/internal/modules/service/session_test.go +++ b/src/server/api/go/internal/modules/service/session_test.go @@ -86,6 +86,14 @@ func (m *MockSessionRepo) ListAllMessagesBySession(ctx context.Context, sessionI return args.Get(0).([]model.Message), args.Error(1) } +func (m *MockSessionRepo) ListMessageBranchPath(ctx context.Context, sessionID uuid.UUID, messageID uuid.UUID) ([]model.Message, error) { + args := m.Called(ctx, sessionID, messageID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]model.Message), args.Error(1) +} + func (m *MockSessionRepo) GetObservingStatus(ctx context.Context, sessionID string) (*model.MessageObservingStatus, error) { args := m.Called(ctx, sessionID) if args.Get(0) == nil { @@ -1347,6 +1355,7 @@ func TestSessionService_StoreMessage_ParentID(t *testing.T) { func TestSessionService_GetMessages(t *testing.T) { ctx := context.Background() sessionID := uuid.New() + branchMessageID := uuid.New() projectID := uuid.New() matchSession := &model.Session{ID: sessionID, ProjectID: projectID} @@ -1470,6 +1479,44 @@ func TestSessionService_GetMessages(t *testing.T) { }, wantErr: true, }, + { + name: "branch_message_id retrieves root-to-target branch path", + input: GetMessagesInput{ + SessionID: sessionID, + BranchMessageID: &branchMessageID, + }, + setup: func(repo *MockSessionRepo) { + msgs := []model.Message{ + {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, + {ID: branchMessageID, SessionID: sessionID, Role: model.RoleAssistant}, + } + repo.On("ListMessageBranchPath", ctx, sessionID, branchMessageID).Return(msgs, nil) + }, + wantErr: false, + }, + { + name: "branch_message_id with limit returns error", + input: GetMessagesInput{ + SessionID: sessionID, + BranchMessageID: &branchMessageID, + Limit: 10, + }, + setup: func(repo *MockSessionRepo) {}, + wantErr: true, + errMsg: "branch_message_id cannot be combined", + }, + { + name: "branch_message_id missing target returns not found error", + input: GetMessagesInput{ + SessionID: sessionID, + BranchMessageID: &branchMessageID, + }, + setup: func(repo *MockSessionRepo) { + repo.On("ListMessageBranchPath", ctx, sessionID, branchMessageID).Return(nil, gorm.ErrRecordNotFound) + }, + wantErr: true, + errMsg: "branch message not found", + }, } for _, tt := range tests { From f2bd0b0f55927991e7d286da3fe086a428b77c0c Mon Sep 17 00:00:00 2001 From: riturajFi Date: Fri, 27 Mar 2026 22:51:34 +0530 Subject: [PATCH 07/19] fix(session): make reads branch-aware --- .../go/internal/modules/service/session.go | 136 ++++++++++++++--- .../internal/modules/service/session_test.go | 138 +++++++++++++++--- 2 files changed, 239 insertions(+), 35 deletions(-) diff --git a/src/server/api/go/internal/modules/service/session.go b/src/server/api/go/internal/modules/service/session.go index 75cb94798..c11c1da42 100644 --- a/src/server/api/go/internal/modules/service/session.go +++ b/src/server/api/go/internal/modules/service/session.go @@ -479,6 +479,87 @@ type GetMessagesOutput struct { EditAtMessageID string `json:"edit_at_message_id,omitempty"` } +func latestLeafMessageID(messages []model.Message) (uuid.UUID, int, bool) { + if len(messages) == 0 { + return uuid.Nil, 0, false + } + + hasChild := make(map[uuid.UUID]struct{}, len(messages)) + for _, msg := range messages { + if msg.ParentID != nil { + hasChild[*msg.ParentID] = struct{}{} + } + } + + var latest model.Message + found := false + leafCount := 0 + for _, msg := range messages { + if _, ok := hasChild[msg.ID]; ok { + continue + } + leafCount++ + if !found || msg.CreatedAt.After(latest.CreatedAt) || (msg.CreatedAt.Equal(latest.CreatedAt) && msg.ID.String() > latest.ID.String()) { + latest = msg + found = true + } + } + + if !found { + return uuid.Nil, leafCount, false + } + + return latest.ID, leafCount, true +} + +func windowMessagesForRead(messages []model.Message, limit int, cursor string, timeDesc bool) ([]model.Message, error) { + if limit <= 0 { + return messages, nil + } + + ordered := make([]model.Message, len(messages)) + copy(ordered, messages) + + if timeDesc { + for i, j := 0, len(ordered)-1; i < j; i, j = i+1, j-1 { + ordered[i], ordered[j] = ordered[j], ordered[i] + } + } + + if cursor != "" { + afterT, afterID, err := paging.DecodeCursor(cursor) + if err != nil { + return nil, err + } + + start := 0 + for start < len(ordered) { + msg := ordered[start] + if timeDesc { + if msg.CreatedAt.Before(afterT) || (msg.CreatedAt.Equal(afterT) && msg.ID.String() < afterID.String()) { + break + } + } else { + if msg.CreatedAt.After(afterT) || (msg.CreatedAt.Equal(afterT) && msg.ID.String() > afterID.String()) { + break + } + } + start++ + } + + if start >= len(ordered) { + return []model.Message{}, nil + } + ordered = ordered[start:] + } + + if len(ordered) > limit+1 { + ordered = ordered[:limit+1] + } + + return ordered, nil +} + func (s *sessionService) GetMessages(ctx context.Context, in GetMessagesInput) (*GetMessagesOutput, error) { // Verify session exists and belongs to project session, err := s.sessionRepo.Get(ctx, &model.Session{ID: in.SessionID}) @@ -490,6 +571,7 @@ func (s *sessionService) GetMessages(ctx context.Context, in GetMessagesInput) ( } var msgs []model.Message + applyBranchWindow := false if in.BranchMessageID != nil { if in.Limit > 0 || in.Cursor != "" || in.TimeDesc { @@ -503,29 +585,49 @@ func (s *sessionService) GetMessages(ctx context.Context, in GetMessagesInput) ( return nil, err } } else { - // Retrieve messages based on limit - if in.Limit <= 0 { - // If limit <= 0, retrieve all messages - msgs, err = s.sessionRepo.ListAllMessagesBySession(ctx, in.SessionID) - if err != nil { - return nil, err + allMessages, err := s.sessionRepo.ListAllMessagesBySession(ctx, in.SessionID) + if err != nil { + return nil, err + } + + if len(allMessages) > 0 { + latestLeafID, leafCount, ok := latestLeafMessageID(allMessages) + if !ok { + return nil, fmt.Errorf("no leaf message found in session") } - } else { - // Parse cursor (createdAt, id); an empty cursor indicates starting from the latest - var afterT time.Time - var afterID uuid.UUID - if in.Cursor != "" { - afterT, afterID, err = paging.DecodeCursor(in.Cursor) + + if leafCount == 1 { + if in.Limit <= 0 { + msgs = allMessages + } else { + var afterT time.Time + var afterID uuid.UUID + if in.Cursor != "" { + afterT, afterID, err = paging.DecodeCursor(in.Cursor) + if err != nil { + return nil, err + } + } + + msgs, err = s.sessionRepo.ListBySessionWithCursor(ctx, in.SessionID, afterT, afterID, in.Limit+1, in.TimeDesc) + if err != nil { + return nil, err + } + } + } else { + msgs, err = s.sessionRepo.ListMessageBranchPath(ctx, in.SessionID, latestLeafID) if err != nil { return nil, err } + applyBranchWindow = true } + } + } - // Query limit+1 is used to determine has_more - msgs, err = s.sessionRepo.ListBySessionWithCursor(ctx, in.SessionID, afterT, afterID, in.Limit+1, in.TimeDesc) - if err != nil { - return nil, err - } + if applyBranchWindow { + msgs, err = windowMessagesForRead(msgs, in.Limit, in.Cursor, in.TimeDesc) + if err != nil { + return nil, err } } diff --git a/src/server/api/go/internal/modules/service/session_test.go b/src/server/api/go/internal/modules/service/session_test.go index 6ef2ec7ff..f027dae41 100644 --- a/src/server/api/go/internal/modules/service/session_test.go +++ b/src/server/api/go/internal/modules/service/session_test.go @@ -19,6 +19,7 @@ import ( "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" @@ -1376,7 +1377,7 @@ func TestSessionService_GetMessages(t *testing.T) { }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) - repo.On("ListBySessionWithCursor", ctx, sessionID, time.Time{}, uuid.UUID{}, 11, false).Return(nil, errors.New("query failure")) + repo.On("ListAllMessagesBySession", ctx, sessionID).Return(nil, errors.New("query failure")) }, wantErr: true, }, @@ -1390,10 +1391,12 @@ func TestSessionService_GetMessages(t *testing.T) { }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) - msgs := []model.Message{ + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, - } - repo.On("ListBySessionWithCursor", ctx, sessionID, time.Time{}, uuid.UUID{}, 11, false).Return(msgs, nil) + }, nil) + repo.On("ListBySessionWithCursor", ctx, sessionID, time.Time{}, uuid.UUID{}, 11, false).Return([]model.Message{ + {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, + }, nil) }, wantErr: false, }, @@ -1407,10 +1410,12 @@ func TestSessionService_GetMessages(t *testing.T) { }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) - msgs := []model.Message{ + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, - } - repo.On("ListBySessionWithCursor", ctx, sessionID, time.Time{}, uuid.UUID{}, 11, true).Return(msgs, nil) + }, nil) + repo.On("ListBySessionWithCursor", ctx, sessionID, time.Time{}, uuid.UUID{}, 11, true).Return([]model.Message{ + {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, + }, nil) }, wantErr: false, }, @@ -1425,7 +1430,9 @@ func TestSessionService_GetMessages(t *testing.T) { }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) - // Expect an error due to invalid cursor format, so no repo call expected + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ + {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, + }, nil) }, wantErr: true, errMsg: "base64", // The actual error message is about base64 decoding @@ -1440,11 +1447,12 @@ func TestSessionService_GetMessages(t *testing.T) { }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) - msgs := []model.Message{ - {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, - {ID: uuid.New(), SessionID: sessionID, Role: model.RoleAssistant}, - } - repo.On("ListAllMessagesBySession", ctx, sessionID).Return(msgs, nil) + rootID := uuid.New() + leafID := uuid.New() + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ + {ID: rootID, SessionID: sessionID, Role: model.RoleUser}, + {ID: leafID, SessionID: sessionID, ParentID: &rootID, Role: model.RoleAssistant}, + }, nil) }, wantErr: false, }, @@ -1458,10 +1466,12 @@ func TestSessionService_GetMessages(t *testing.T) { }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) - msgs := []model.Message{ - {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, - } - repo.On("ListAllMessagesBySession", ctx, sessionID).Return(msgs, nil) + rootID := uuid.New() + leafID := uuid.New() + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ + {ID: rootID, SessionID: sessionID, Role: model.RoleUser}, + {ID: leafID, SessionID: sessionID, ParentID: &rootID, Role: model.RoleAssistant}, + }, nil) }, wantErr: false, }, @@ -1482,10 +1492,12 @@ func TestSessionService_GetMessages(t *testing.T) { { name: "branch_message_id retrieves root-to-target branch path", input: GetMessagesInput{ + ProjectID: projectID, SessionID: sessionID, BranchMessageID: &branchMessageID, }, setup: func(repo *MockSessionRepo) { + repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) msgs := []model.Message{ {ID: uuid.New(), SessionID: sessionID, Role: model.RoleUser}, {ID: branchMessageID, SessionID: sessionID, Role: model.RoleAssistant}, @@ -1497,21 +1509,26 @@ func TestSessionService_GetMessages(t *testing.T) { { name: "branch_message_id with limit returns error", input: GetMessagesInput{ + ProjectID: projectID, SessionID: sessionID, BranchMessageID: &branchMessageID, Limit: 10, }, - setup: func(repo *MockSessionRepo) {}, + setup: func(repo *MockSessionRepo) { + repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) + }, wantErr: true, errMsg: "branch_message_id cannot be combined", }, { name: "branch_message_id missing target returns not found error", input: GetMessagesInput{ + ProjectID: projectID, SessionID: sessionID, BranchMessageID: &branchMessageID, }, setup: func(repo *MockSessionRepo) { + repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) repo.On("ListMessageBranchPath", ctx, sessionID, branchMessageID).Return(nil, gorm.ErrRecordNotFound) }, wantErr: true, @@ -1561,6 +1578,79 @@ func TestSessionService_GetMessages(t *testing.T) { } } +func TestSessionService_GetMessages_DefaultBranchSelection(t *testing.T) { + ctx := context.Background() + sessionID := uuid.New() + projectID := uuid.New() + rootID := uuid.MustParse("00000000-0000-0000-0000-000000000010") + leftLeafID := uuid.MustParse("00000000-0000-0000-0000-000000000011") + rightLeafID := uuid.MustParse("00000000-0000-0000-0000-000000000012") + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + matchSession := &model.Session{ID: sessionID, ProjectID: projectID} + + repo := &MockSessionRepo{} + repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ + { + ID: leftLeafID, + SessionID: sessionID, + ParentID: &rootID, + Role: model.RoleUser, + CreatedAt: baseTime.Add(2 * time.Hour), + }, + { + ID: rootID, + SessionID: sessionID, + Role: model.RoleUser, + CreatedAt: baseTime, + }, + { + ID: rightLeafID, + SessionID: sessionID, + ParentID: &rootID, + Role: model.RoleAssistant, + CreatedAt: baseTime.Add(2 * time.Hour), + }, + }, nil) + repo.On("ListMessageBranchPath", ctx, sessionID, rightLeafID).Return([]model.Message{ + { + ID: rootID, + SessionID: sessionID, + Role: model.RoleUser, + CreatedAt: baseTime, + }, + { + ID: rightLeafID, + SessionID: sessionID, + ParentID: &rootID, + Role: model.RoleAssistant, + CreatedAt: baseTime.Add(2 * time.Hour), + }, + }, nil) + + logger := zap.NewNop() + mockAssetRefRepo := &MockAssetReferenceRepo{} + cfg := &config.Config{ + RabbitMQ: config.MQCfg{ + ExchangeName: config.MQExchangeName{ + SessionMessage: "session.message", + }, + RoutingKey: config.MQRoutingKey{ + SessionMessageInsert: "session.message.insert", + }, + }, + } + service := NewSessionService(repo, nil, mockAssetRefRepo, nil, logger, nil, nil, cfg, nil, nil) + + result, err := service.GetMessages(ctx, GetMessagesInput{ProjectID: projectID, SessionID: sessionID}) + require.NoError(t, err) + require.Len(t, result.Items, 2) + assert.Equal(t, rootID, result.Items[0].ID) + assert.Equal(t, rightLeafID, result.Items[1].ID) + + repo.AssertExpectations(t) +} + func TestSessionService_GetMessages_SortOrder(t *testing.T) { ctx := context.Background() sessionID := uuid.New() @@ -1599,6 +1689,9 @@ func TestSessionService_GetMessages_SortOrder(t *testing.T) { expectedOrder: []uuid.UUID{msg1ID, msg2ID, msg3ID}, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ + {ID: msg1ID, SessionID: sessionID, Role: model.RoleUser, CreatedAt: now.Add(-3 * time.Hour)}, + }, nil) msgs := []model.Message{ {ID: msg1ID, SessionID: sessionID, Role: model.RoleUser, CreatedAt: now.Add(-3 * time.Hour)}, {ID: msg2ID, SessionID: sessionID, Role: model.RoleAssistant, CreatedAt: now.Add(-2 * time.Hour)}, @@ -1624,6 +1717,9 @@ func TestSessionService_GetMessages_SortOrder(t *testing.T) { expectedOrder: []uuid.UUID{msg1ID, msg2ID, msg3ID}, // Still old to new setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ + {ID: msg1ID, SessionID: sessionID, Role: model.RoleUser, CreatedAt: now.Add(-3 * time.Hour)}, + }, nil) // Repo returns messages in descending order (newest first) msgs := []model.Message{ {ID: msg3ID, SessionID: sessionID, Role: model.RoleUser, CreatedAt: now.Add(-1 * time.Hour)}, @@ -1651,6 +1747,9 @@ func TestSessionService_GetMessages_SortOrder(t *testing.T) { expectedOrder: []uuid.UUID{msg1ID, msg2ID, msg4ID}, // Assuming these IDs sort this way lexicographically setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ + {ID: msg1ID, SessionID: sessionID, Role: model.RoleUser, CreatedAt: now}, + }, nil) msgs := []model.Message{ {ID: msg4ID, SessionID: sessionID, Role: model.RoleUser, CreatedAt: now}, {ID: msg2ID, SessionID: sessionID, Role: model.RoleAssistant, CreatedAt: now}, @@ -1677,6 +1776,9 @@ func TestSessionService_GetMessages_SortOrder(t *testing.T) { expectedOrder: []uuid.UUID{msg1ID, msg2ID, msg3ID, msg4ID}, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) + repo.On("ListAllMessagesBySession", ctx, sessionID).Return([]model.Message{ + {ID: msg1ID, SessionID: sessionID, Role: model.RoleUser, CreatedAt: now.Add(-3 * time.Hour)}, + }, nil) // Repo returns messages in random order msgs := []model.Message{ {ID: msg2ID, SessionID: sessionID, Role: model.RoleAssistant, CreatedAt: now.Add(-2 * time.Hour)}, From ca31f799952f323525a0d3573dc9f5b9d87e5214 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Sat, 28 Mar 2026 00:05:16 +0530 Subject: [PATCH 08/19] fix(core): serialize same-branch processing --- .../acontext_core/service/data/message.py | 84 ++++++------ .../acontext_core/service/session_message.py | 38 +++++- .../tests/service/test_buffer_timer_dedup.py | 61 ++++++++- .../core/tests/service/test_flush_retries.py | 127 ++++++++++++++++++ .../service/test_message_data_branch_path.py | 48 +++++++ 5 files changed, 310 insertions(+), 48 deletions(-) diff --git a/src/server/core/acontext_core/service/data/message.py b/src/server/core/acontext_core/service/data/message.py index e69121bfe..d41534ecd 100644 --- a/src/server/core/acontext_core/service/data/message.py +++ b/src/server/core/acontext_core/service/data/message.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import List +from typing import List, Any from sqlalchemy import select, func, text from sqlalchemy.ext.asyncio import AsyncSession from pydantic import ValidationError @@ -151,33 +151,62 @@ async def fetch_message_branch_path_ids( Returns: Result containing message IDs ordered from root to target message """ + r = await fetch_message_branch_path_rows(db_session, message_id, session_id) + rows, eil = r.unpack() + if eil: + return Result.reject(str(eil)) + return Result.resolve([row["id"] for row in rows]) + + +async def fetch_message_branch_path_rows( + db_session: AsyncSession, + message_id: asUUID, + session_id: asUUID | None = None, +) -> Result[list[dict[str, Any]]]: + """ + Fetch one message's branch path from root to the target message. + + Uses a recursive CTE to walk parent_id upward in one query. + + Args: + db_session: Database session + message_id: Leaf or intermediate message UUID to start from + session_id: Optional session UUID to verify the full path belongs to + + Returns: + Result containing branch path rows ordered from root to target message + """ try: query = text( """ WITH RECURSIVE message_path AS ( - SELECT id, parent_id, session_id, 0 AS depth + SELECT id, parent_id, session_id, session_task_process_status, 0 AS depth FROM messages WHERE id = :message_id UNION ALL - SELECT parent.id, parent.parent_id, parent.session_id, child.depth + 1 AS depth + SELECT + parent.id, + parent.parent_id, + parent.session_id, + parent.session_task_process_status, + child.depth + 1 AS depth FROM messages AS parent JOIN message_path AS child ON parent.id = child.parent_id ) - SELECT id, session_id, depth + SELECT id, parent_id, session_id, session_task_process_status, depth FROM message_path ORDER BY depth DESC, id ASC """ ) result = await db_session.execute(query, {"message_id": message_id}) - rows = result.mappings().all() + rows = [dict(row) for row in result.mappings().all()] if not rows: return Result.reject(f"Message {message_id} doesn't exist") - path_ids = [row["id"] for row in rows] path_session_ids = {row["session_id"] for row in rows} if session_id is not None and path_session_ids != {session_id}: @@ -190,7 +219,7 @@ async def fetch_message_branch_path_ids( f"Message {message_id} has an invalid cross-session parent chain" ) - return Result.resolve(path_ids) + return Result.resolve(rows) except Exception as e: return Result.reject( f"Error fetching branch path for message {message_id}: {e}" @@ -216,43 +245,12 @@ async def branch_pending_message_length( Result containing the count of matching messages on the branch path """ try: - query = text( - """ - WITH RECURSIVE message_path AS ( - SELECT id, parent_id, session_id, session_task_process_status - FROM messages - WHERE id = :message_id + r = await fetch_message_branch_path_rows(db_session, message_id, session_id) + rows, eil = r.unpack() + if eil: + return Result.reject(str(eil)) - UNION ALL - - SELECT parent.id, parent.parent_id, parent.session_id, parent.session_task_process_status - FROM messages AS parent - JOIN message_path AS child - ON parent.id = child.parent_id - ) - SELECT id, session_id, session_task_process_status - FROM message_path - """ - ) - result = await db_session.execute(query, {"message_id": message_id}) - rows = result.mappings().all() - - if not rows: - return Result.reject(f"Message {message_id} doesn't exist") - - path_session_ids = {row["session_id"] for row in rows} - if session_id is not None and path_session_ids != {session_id}: - return Result.reject( - f"Message {message_id} does not belong to session {session_id}" - ) - if len(path_session_ids) != 1: - return Result.reject( - f"Message {message_id} has an invalid cross-session parent chain" - ) - - count = sum( - 1 for row in rows if row["session_task_process_status"] == status - ) + count = sum(1 for row in rows if row["session_task_process_status"] == status) return Result.resolve(count) except Exception as e: return Result.reject( diff --git a/src/server/core/acontext_core/service/session_message.py b/src/server/core/acontext_core/service/session_message.py index 00ce4881a..b693d1376 100644 --- a/src/server/core/acontext_core/service/session_message.py +++ b/src/server/core/acontext_core/service/session_message.py @@ -14,6 +14,7 @@ from ..schema.utils import asUUID from ..schema.result import Result from ..schema.session.learning_space import SessionStatus +from ..schema.session.task import TaskStatus from .constants import EX, RK from .data import learning_space as LS from .data import message as MD @@ -29,6 +30,22 @@ def _insert_message_lock_key(session_id: asUUID, message_id: asUUID) -> str: return f"session.message.insert.{session_id}.{message_id}" +_BRANCH_LOCK_TERMINAL_STATUSES = { + TaskStatus.SUCCESS.value, + TaskStatus.FAILED.value, + TaskStatus.DISABLE_TRACKING.value, + TaskStatus.LIMIT_EXCEED.value, +} + + +def _branch_lock_message_id(message_rows: list[dict]) -> asUUID: + # Lock on the first unfinished message so all descendants contend on one key. + for row in message_rows: + if row["session_task_process_status"] not in _BRANCH_LOCK_TERMINAL_STATUSES: + return row["id"] + return message_rows[-1]["id"] + + async def _get_pending_session_message_ids(session_id: asUUID) -> Result[list[asUUID]]: async with DB_CLIENT.get_session_context() as session: return await MD.get_message_ids(session, session_id, limit=None, asc=True) @@ -81,7 +98,14 @@ async def insert_new_message(body: InsertNewMessage, message: Message): ) return - lock_key = _insert_message_lock_key(body.session_id, body.message_id) + async with DB_CLIENT.get_session_context() as session: + r = await MD.fetch_message_branch_path_rows(session, body.message_id, body.session_id) + message_rows, eil = r.unpack() + if eil: + return + + lock_message_id = _branch_lock_message_id(message_rows) + lock_key = _insert_message_lock_key(body.session_id, lock_message_id) _l = await check_redis_lock_or_set(body.project_id, lock_key) if not _l: wide["lock_acquired"] = False @@ -217,7 +241,17 @@ async def flush_session_message_blocking( if msg_status != "pending": continue - lock_key = _insert_message_lock_key(session_id, message_id) + r = await MD.fetch_message_branch_path_rows( + read_session, message_id, session_id + ) + message_rows, eil = r.unpack() + if eil: + wide_event["outcome"] = "error" + wide_event["error"] = str(eil) + return r + + lock_message_id = _branch_lock_message_id(message_rows) + lock_key = _insert_message_lock_key(session_id, lock_message_id) _l = await check_redis_lock_or_set(project_id, lock_key) if not _l: busy_lock_count += 1 diff --git a/src/server/core/tests/service/test_buffer_timer_dedup.py b/src/server/core/tests/service/test_buffer_timer_dedup.py index 6ae816f73..ebde99d5a 100644 --- a/src/server/core/tests/service/test_buffer_timer_dedup.py +++ b/src/server/core/tests/service/test_buffer_timer_dedup.py @@ -47,6 +47,16 @@ def _mock_db(): return mock_db +def _branch_row(message_id, session_id, status="pending", parent_id=None, depth=0): + return { + "id": message_id, + "parent_id": parent_id, + "session_id": session_id, + "session_task_process_status": status, + "depth": depth, + } + + class TestMessageNotPending: @pytest.mark.asyncio async def test_skips_when_message_already_processed(self): @@ -216,6 +226,11 @@ async def test_processes_immediately_when_at_threshold(self): new_callable=AsyncMock, return_value=Result.resolve(16), ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve([_branch_row(body.message_id, body.session_id)]), + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, @@ -262,6 +277,11 @@ async def test_processes_when_message_still_pending(self): new_callable=AsyncMock, return_value=Result.resolve(2), ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve([_branch_row(body.message_id, body.session_id)]), + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, @@ -390,6 +410,11 @@ async def capture_publish(**kwargs): new_callable=AsyncMock, return_value=Result.resolve(2), ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve([_branch_row(body.message_id, body.session_id)]), + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, @@ -432,6 +457,11 @@ async def capture_publish(**kwargs): new_callable=AsyncMock, return_value=Result.resolve(20), ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve([_branch_row(body.message_id, body.session_id)]), + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, @@ -530,10 +560,12 @@ async def capture_publish(**kwargs): class TestBranchLockKey: @pytest.mark.asyncio - async def test_retry_uses_branch_specific_lock_key(self): - """Lock key for insert processing includes the message id.""" + async def test_retry_uses_branch_boundary_lock_key(self): + """Lock key for insert processing follows the first unfinished branch node.""" body = _make_body(process_rightnow=True) project_config = _make_project_config() + root_id = uuid.uuid4() + branch_id = uuid.uuid4() with ( patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()), @@ -552,6 +584,29 @@ async def test_retry_uses_branch_specific_lock_key(self): new_callable=AsyncMock, return_value=Result.resolve(16), ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve( + [ + _branch_row(root_id, body.session_id, status="success", depth=0), + _branch_row( + branch_id, + body.session_id, + status="pending", + parent_id=root_id, + depth=1, + ), + _branch_row( + body.message_id, + body.session_id, + status="pending", + parent_id=branch_id, + depth=2, + ), + ] + ), + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, @@ -561,5 +616,5 @@ async def test_retry_uses_branch_specific_lock_key(self): ): await insert_new_message(body, MagicMock()) - expected_key = f"session.message.insert.{body.session_id}.{body.message_id}" + expected_key = f"session.message.insert.{body.session_id}.{branch_id}" mock_lock.assert_called_once_with(body.project_id, expected_key) diff --git a/src/server/core/tests/service/test_flush_retries.py b/src/server/core/tests/service/test_flush_retries.py index 9a3f0ded0..9cc0bfd53 100644 --- a/src/server/core/tests/service/test_flush_retries.py +++ b/src/server/core/tests/service/test_flush_retries.py @@ -25,6 +25,16 @@ def _mock_db(): return mock_db +def _branch_row(message_id, session_id, status="pending", parent_id=None, depth=0): + return { + "id": message_id, + "parent_id": parent_id, + "session_id": session_id, + "session_task_process_status": status, + "depth": depth, + } + + class TestFlushBranchAwareProcessing: @pytest.mark.asyncio async def test_processes_each_pending_message_individually(self): @@ -53,6 +63,14 @@ async def test_processes_each_pending_message_individually(self): new_callable=AsyncMock, return_value=Result.resolve("pending"), ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + side_effect=[ + Result.resolve([_branch_row(message_a, session_id)]), + Result.resolve([_branch_row(message_b, session_id)]), + ], + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, @@ -111,6 +129,11 @@ async def test_retries_when_branch_lock_is_busy_then_processes(self): new_callable=AsyncMock, return_value=Result.resolve("pending"), ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve([_branch_row(message_id, session_id)]), + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, @@ -166,6 +189,11 @@ async def test_returns_reject_after_max_retries_when_branch_locks_stay_busy(self new_callable=AsyncMock, return_value=Result.resolve("pending"), ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve([_branch_row(message_id, session_id)]), + ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, @@ -192,3 +220,102 @@ async def test_returns_reject_after_max_retries_when_branch_locks_stay_busy(self assert "retries" in result.error.errmsg.lower() assert mock_lock.call_count == max_retries mock_release.assert_not_called() + + +class TestFlushSharedBranchLocking: + @pytest.mark.asyncio + async def test_same_branch_messages_share_one_lock_boundary(self): + project_id = uuid.uuid4() + session_id = uuid.uuid4() + root_id = uuid.uuid4() + branch_id = uuid.uuid4() + leaf_id = uuid.uuid4() + + with ( + patch("acontext_core.service.session_message.DB_CLIENT", _mock_db()), + patch( + "acontext_core.service.session_message.PD.get_project_config", + new_callable=AsyncMock, + return_value=Result.resolve(MagicMock()), + ), + patch( + "acontext_core.service.session_message._get_pending_session_message_ids", + new_callable=AsyncMock, + side_effect=[ + Result.resolve([branch_id, leaf_id]), + Result.resolve([]), + ], + ), + patch( + "acontext_core.service.session_message.MD.check_session_message_status", + new_callable=AsyncMock, + return_value=Result.resolve("pending"), + ), + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + side_effect=[ + Result.resolve( + [ + _branch_row(root_id, session_id, status="success", depth=0), + _branch_row( + branch_id, + session_id, + status="pending", + parent_id=root_id, + depth=1, + ), + ] + ), + Result.resolve( + [ + _branch_row(root_id, session_id, status="success", depth=0), + _branch_row( + branch_id, + session_id, + status="pending", + parent_id=root_id, + depth=1, + ), + _branch_row( + leaf_id, + session_id, + status="pending", + parent_id=branch_id, + depth=2, + ), + ] + ), + ], + ), + patch( + "acontext_core.service.session_message.check_redis_lock_or_set", + new_callable=AsyncMock, + return_value=True, + ) as mock_lock, + patch( + "acontext_core.service.session_message.release_redis_lock", + new_callable=AsyncMock, + ) as mock_release, + patch( + "acontext_core.service.session_message.MC.process_inserted_message", + new_callable=AsyncMock, + return_value=Result.resolve(None), + ) as mock_process, + ): + result = await flush_session_message_blocking(project_id, session_id) + + assert result.ok() + expected_key = f"session.message.insert.{session_id}.{branch_id}" + assert mock_lock.call_args_list == [ + call(project_id, expected_key), + call(project_id, expected_key), + ] + assert mock_release.call_args_list == [ + call(project_id, expected_key), + call(project_id, expected_key), + ] + assert mock_process.call_args_list == [ + call(ANY, project_id, session_id, branch_id), + call(ANY, project_id, session_id, leaf_id), + ] diff --git a/src/server/core/tests/service/test_message_data_branch_path.py b/src/server/core/tests/service/test_message_data_branch_path.py index 9d4306974..254d2f6d7 100644 --- a/src/server/core/tests/service/test_message_data_branch_path.py +++ b/src/server/core/tests/service/test_message_data_branch_path.py @@ -119,6 +119,54 @@ async def test_fetch_message_branch_path_ids_rejects_missing_message(db_client): assert "doesn't exist" in str(eil) +@pytest.mark.asyncio +async def test_fetch_message_branch_path_rows_returns_statuses(db_client): + _, acontext_session = await _create_project_and_session(db_client) + + async with db_client.get_session_context() as session: + root = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={}, + session_task_process_status="success", + ) + session.add(root) + await session.flush() + + child = Message( + session_id=acontext_session.id, + role="assistant", + parts_asset_meta={}, + parent_id=root.id, + session_task_process_status="pending", + ) + session.add(child) + await session.flush() + + leaf = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={}, + parent_id=child.id, + session_task_process_status="running", + ) + session.add(leaf) + await session.flush() + + r = await MD.fetch_message_branch_path_rows( + session, leaf.id, acontext_session.id + ) + rows, eil = r.unpack() + + assert eil is None + assert [row["id"] for row in rows] == [root.id, child.id, leaf.id] + assert [row["session_task_process_status"] for row in rows] == [ + "success", + "pending", + "running", + ] + + @pytest.mark.asyncio async def test_branch_pending_message_length_ignores_sibling_branches(db_client): _, acontext_session = await _create_project_and_session(db_client) From 5d6434926ec7907977f5a638a71d82775b243969 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Sun, 29 Mar 2026 05:40:34 +0530 Subject: [PATCH 09/19] replay issue solved --- .../service/controller/message.py | 29 +++- .../service/test_process_inserted_message.py | 144 ++++++++++++++++-- 2 files changed, 158 insertions(+), 15 deletions(-) diff --git a/src/server/core/acontext_core/service/controller/message.py b/src/server/core/acontext_core/service/controller/message.py index 91d71e188..6e3c61f5d 100644 --- a/src/server/core/acontext_core/service/controller/message.py +++ b/src/server/core/acontext_core/service/controller/message.py @@ -13,6 +13,20 @@ from ...constants import ExcessMetricTags +_CLAIMABLE_BRANCH_STATUSES = { + TaskStatus.PENDING.value, + TaskStatus.RUNNING.value, +} + + +def _claimable_branch_message_ids(message_rows: list[dict]) -> list[asUUID]: + return [ + row["id"] + for row in message_rows + if row["session_task_process_status"] in _CLAIMABLE_BRANCH_STATUSES + ] + + async def _try_rollback_to_failed(pending_message_ids: list) -> None: try: async with DB_CLIENT.get_session_context() as rollback_session: @@ -35,10 +49,23 @@ async def process_inserted_message( ) -> Result[None]: wide = get_wide_event() disabled = await get_metrics(project_id, ExcessMetricTags.new_task_created) - target_message_ids = [message_id] + target_message_ids: list[asUUID] = [] try: async with DB_CLIENT.get_session_context() as session: + r = await MD.fetch_message_branch_path_rows(session, message_id, session_id) + message_rows, eil = r.unpack() + if eil: + return r + + target_message_ids = _claimable_branch_message_ids(message_rows) + wide["claimed_branch_message_count"] = len(target_message_ids) + + if not target_message_ids: + wide["project_disabled"] = bool(disabled) + wide["task_agent_outcome"] = "skip_no_claimable_messages" + return Result.resolve(None) + if disabled: wide["project_disabled"] = True await MD.update_message_status_to( diff --git a/src/server/core/tests/service/test_process_inserted_message.py b/src/server/core/tests/service/test_process_inserted_message.py index d329047a9..073652803 100644 --- a/src/server/core/tests/service/test_process_inserted_message.py +++ b/src/server/core/tests/service/test_process_inserted_message.py @@ -2,7 +2,8 @@ Tests for process_inserted_message. Covers: -- success path with branch-only messages +- success path with pending branch suffix messages +- disabled project claims the whole pending branch suffix - branch-load error rollback - task-agent exception rollback """ @@ -52,20 +53,45 @@ def _make_message(message_id: uuid.UUID, role: str, parent_id: uuid.UUID | None return message +def _branch_row( + message_id: uuid.UUID, + status: str, + parent_id: uuid.UUID | None = None, +): + return { + "id": message_id, + "parent_id": parent_id, + "session_id": _SESSION_ID, + "session_task_process_status": status, + } + + class TestProcessInsertedMessage: @pytest.mark.asyncio - async def test_marks_inserted_message_running_then_success(self): + async def test_marks_pending_branch_suffix_running_then_success(self): update_status = AsyncMock() root_id = uuid.uuid4() + branch_id = uuid.uuid4() branch_messages = [ _make_message(root_id, "user"), - _make_message(_MESSAGE_ID, "assistant", parent_id=root_id), + _make_message(branch_id, "assistant", parent_id=root_id), + _make_message(_MESSAGE_ID, "user", parent_id=branch_id), + ] + branch_rows = [ + _branch_row(root_id, TaskStatus.SUCCESS.value), + _branch_row(branch_id, TaskStatus.PENDING.value, parent_id=root_id), + _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=branch_id), ] with ( patch(f"{MODULE}.DB_CLIENT", _mock_db()), patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch( + f"{MODULE}.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve(branch_rows), + ), patch( f"{MODULE}.MD.update_message_status_to", update_status, @@ -96,11 +122,11 @@ async def test_marks_inserted_message_running_then_success(self): assert result.ok() assert update_status.call_args_list[0].args[1:] == ( - [_MESSAGE_ID], + [branch_id, _MESSAGE_ID], TaskStatus.RUNNING, ) assert update_status.call_args_list[-1].args[1:] == ( - [_MESSAGE_ID], + [branch_id, _MESSAGE_ID], TaskStatus.SUCCESS, ) @@ -108,24 +134,36 @@ async def test_marks_inserted_message_running_then_success(self): assert [message.message_id for message in task_messages] == [ branch_messages[0].id, branch_messages[1].id, + branch_messages[2].id, ] assert [message.parent_id for message in task_messages] == [ None, root_id, + branch_id, ] @pytest.mark.asyncio async def test_forwards_user_kek_to_branch_path_fetch(self): update_status = AsyncMock() + root_id = uuid.uuid4() branch_messages = [ - _make_message(uuid.uuid4(), "user"), - _make_message(_MESSAGE_ID, "assistant"), + _make_message(root_id, "user"), + _make_message(_MESSAGE_ID, "assistant", parent_id=root_id), + ] + branch_rows = [ + _branch_row(root_id, TaskStatus.SUCCESS.value), + _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=root_id), ] with ( patch(f"{MODULE}.DB_CLIENT", _mock_db()), patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch( + f"{MODULE}.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve(branch_rows), + ), patch(f"{MODULE}.MD.update_message_status_to", update_status), patch( f"{MODULE}.MD.fetch_message_branch_path_data", @@ -160,13 +198,79 @@ async def test_forwards_user_kek_to_branch_path_fetch(self): ) @pytest.mark.asyncio - async def test_branch_load_error_rolls_back_only_inserted_message(self): + async def test_project_disabled_marks_pending_branch_suffix_limit_exceed(self): update_status = AsyncMock() + root_id = uuid.uuid4() + branch_id = uuid.uuid4() + branch_rows = [ + _branch_row(root_id, TaskStatus.SUCCESS.value), + _branch_row(branch_id, TaskStatus.PENDING.value, parent_id=root_id), + _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=branch_id), + ] + + with ( + patch(f"{MODULE}.DB_CLIENT", _mock_db()), + patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=True), + patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch( + f"{MODULE}.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve(branch_rows), + ), + patch( + f"{MODULE}.MD.update_message_status_to", + update_status, + ), + patch( + f"{MODULE}.MD.fetch_message_branch_path_data", + new_callable=AsyncMock, + ) as mock_branch_data, + patch( + f"{MODULE}.LS.get_learning_space_for_session", + new_callable=AsyncMock, + ) as mock_ls, + patch( + f"{MODULE}.AT.task_agent_curd", + new_callable=AsyncMock, + ) as mock_task_agent, + ): + result = await process_inserted_message( + _mock_project_config(), + _PROJECT_ID, + _SESSION_ID, + _MESSAGE_ID, + ) + + assert result.ok() + update_status.assert_called_once() + assert update_status.call_args.args[1:] == ( + [branch_id, _MESSAGE_ID], + TaskStatus.LIMIT_EXCEED, + ) + mock_branch_data.assert_not_called() + mock_ls.assert_not_called() + mock_task_agent.assert_not_called() + + @pytest.mark.asyncio + async def test_branch_load_error_rolls_back_pending_branch_suffix(self): + update_status = AsyncMock() + root_id = uuid.uuid4() + branch_id = uuid.uuid4() + branch_rows = [ + _branch_row(root_id, TaskStatus.SUCCESS.value), + _branch_row(branch_id, TaskStatus.PENDING.value, parent_id=root_id), + _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=branch_id), + ] with ( patch(f"{MODULE}.DB_CLIENT", _mock_db()), patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch( + f"{MODULE}.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve(branch_rows), + ), patch( f"{MODULE}.MD.update_message_status_to", update_status, @@ -194,29 +298,41 @@ async def test_branch_load_error_rolls_back_only_inserted_message(self): assert not result.ok() assert update_status.call_args_list[0].args[1:] == ( - [_MESSAGE_ID], + [branch_id, _MESSAGE_ID], TaskStatus.RUNNING, ) assert update_status.call_args_list[-1].args[1:] == ( - [_MESSAGE_ID], + [branch_id, _MESSAGE_ID], TaskStatus.FAILED, ) mock_ls.assert_not_called() mock_task_agent.assert_not_called() @pytest.mark.asyncio - async def test_task_agent_exception_rolls_back_only_inserted_message(self): + async def test_task_agent_exception_rolls_back_pending_branch_suffix(self): update_status = AsyncMock() root_id = uuid.uuid4() + branch_id = uuid.uuid4() branch_messages = [ _make_message(root_id, "user"), - _make_message(_MESSAGE_ID, "assistant", parent_id=root_id), + _make_message(branch_id, "assistant", parent_id=root_id), + _make_message(_MESSAGE_ID, "user", parent_id=branch_id), + ] + branch_rows = [ + _branch_row(root_id, TaskStatus.SUCCESS.value), + _branch_row(branch_id, TaskStatus.RUNNING.value, parent_id=root_id), + _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=branch_id), ] with ( patch(f"{MODULE}.DB_CLIENT", _mock_db()), patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), + patch( + f"{MODULE}.MD.fetch_message_branch_path_rows", + new_callable=AsyncMock, + return_value=Result.resolve(branch_rows), + ), patch( f"{MODULE}.MD.update_message_status_to", update_status, @@ -246,10 +362,10 @@ async def test_task_agent_exception_rolls_back_only_inserted_message(self): ) assert update_status.call_args_list[0].args[1:] == ( - [_MESSAGE_ID], + [branch_id, _MESSAGE_ID], TaskStatus.RUNNING, ) assert update_status.call_args_list[-1].args[1:] == ( - [_MESSAGE_ID], + [branch_id, _MESSAGE_ID], TaskStatus.FAILED, ) From 93d0f5f0cc96f484721b188f4fec301e65ba7bdd Mon Sep 17 00:00:00 2001 From: riturajFi Date: Sun, 29 Mar 2026 05:48:08 +0530 Subject: [PATCH 10/19] correct error incase of wrong uuid --- .../go/internal/modules/handler/session.go | 4 ++ .../internal/modules/handler/session_test.go | 46 +++++++++++++++++++ .../api/go/internal/modules/service/errors.go | 4 +- .../go/internal/modules/service/session.go | 9 +++- .../internal/modules/service/session_test.go | 2 + 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/server/api/go/internal/modules/handler/session.go b/src/server/api/go/internal/modules/handler/session.go index 638765bb1..35bbce2b6 100644 --- a/src/server/api/go/internal/modules/handler/session.go +++ b/src/server/api/go/internal/modules/handler/session.go @@ -488,6 +488,10 @@ func (h *SessionHandler) StoreMessage(c *gin.Context) { UserKEK: middleware.GetUserKEKIfEncrypted(c), }) if err != nil { + if errors.Is(err, service.ErrParentMessageNotFound) || errors.Is(err, service.ErrParentMessageWrongSession) { + c.JSON(http.StatusNotFound, serializer.Err(http.StatusNotFound, err.Error(), nil)) + return + } c.JSON(http.StatusBadRequest, serializer.DBErr("", err)) return } diff --git a/src/server/api/go/internal/modules/handler/session_test.go b/src/server/api/go/internal/modules/handler/session_test.go index 265b12efa..bc73987c8 100644 --- a/src/server/api/go/internal/modules/handler/session_test.go +++ b/src/server/api/go/internal/modules/handler/session_test.go @@ -812,6 +812,52 @@ func TestSessionHandler_StoreMessage(t *testing.T) { setup: func(svc *MockSessionService) {}, expectedStatus: http.StatusBadRequest, }, + { + name: "parent message not found maps to 404", + sessionIDParam: sessionID.String(), + requestBody: map[string]interface{}{ + "format": "acontext", + "parent_id": jsonParentID.String(), + "blob": map[string]interface{}{ + "role": "user", + "parts": []map[string]interface{}{ + { + "type": "text", + "text": "fork here", + }, + }, + }, + }, + setup: func(svc *MockSessionService) { + svc.On("StoreMessage", mock.Anything, mock.MatchedBy(func(in service.StoreMessageInput) bool { + return in.ParentID != nil && *in.ParentID == jsonParentID + })).Return(nil, service.ErrParentMessageNotFound) + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "parent message wrong session maps to 404", + sessionIDParam: sessionID.String(), + requestBody: map[string]interface{}{ + "format": "acontext", + "parent_id": jsonParentID.String(), + "blob": map[string]interface{}{ + "role": "user", + "parts": []map[string]interface{}{ + { + "type": "text", + "text": "fork here", + }, + }, + }, + }, + setup: func(svc *MockSessionService) { + svc.On("StoreMessage", mock.Anything, mock.MatchedBy(func(in service.StoreMessageInput) bool { + return in.ParentID != nil && *in.ParentID == jsonParentID + })).Return(nil, service.ErrParentMessageWrongSession) + }, + expectedStatus: http.StatusNotFound, + }, { name: "openai format - multipart content with text and image", sessionIDParam: sessionID.String(), diff --git a/src/server/api/go/internal/modules/service/errors.go b/src/server/api/go/internal/modules/service/errors.go index 7e861799f..275c917b1 100644 --- a/src/server/api/go/internal/modules/service/errors.go +++ b/src/server/api/go/internal/modules/service/errors.go @@ -10,5 +10,7 @@ var ( ErrCopyFailed = errors.New("failed to copy session") // General session errors - ErrUnauthorized = errors.New("unauthorized access to session") + ErrUnauthorized = errors.New("unauthorized access to session") + ErrParentMessageNotFound = errors.New("parent message not found") + ErrParentMessageWrongSession = errors.New("parent message does not belong to session") ) diff --git a/src/server/api/go/internal/modules/service/session.go b/src/server/api/go/internal/modules/service/session.go index c11c1da42..330fd1d06 100644 --- a/src/server/api/go/internal/modules/service/session.go +++ b/src/server/api/go/internal/modules/service/session.go @@ -311,12 +311,17 @@ func (s *sessionService) StoreMessage(ctx context.Context, in StoreMessageInput) parent, err := s.sessionRepo.GetMessageByIDAnySession(ctx, *in.ParentID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("parent message not found") + return nil, fmt.Errorf("%w: %s", ErrParentMessageNotFound, in.ParentID.String()) } return nil, fmt.Errorf("failed to get parent message: %w", err) } if parent.SessionID != in.SessionID { - return nil, fmt.Errorf("parent message does not belong to session") + return nil, fmt.Errorf( + "%w: parent_id=%s session_id=%s", + ErrParentMessageWrongSession, + in.ParentID.String(), + in.SessionID.String(), + ) } } diff --git a/src/server/api/go/internal/modules/service/session_test.go b/src/server/api/go/internal/modules/service/session_test.go index f027dae41..47d58beb6 100644 --- a/src/server/api/go/internal/modules/service/session_test.go +++ b/src/server/api/go/internal/modules/service/session_test.go @@ -1256,6 +1256,7 @@ func TestSessionService_StoreMessage_ParentID(t *testing.T) { result, err := service.StoreMessage(ctx, input) assert.Error(t, err) assert.Nil(t, result) + assert.ErrorIs(t, err, ErrParentMessageNotFound) assert.Contains(t, err.Error(), "parent message not found") repo.AssertExpectations(t) @@ -1281,6 +1282,7 @@ func TestSessionService_StoreMessage_ParentID(t *testing.T) { result, err := service.StoreMessage(ctx, input) assert.Error(t, err) assert.Nil(t, result) + assert.ErrorIs(t, err, ErrParentMessageWrongSession) assert.Contains(t, err.Error(), "parent message does not belong to session") repo.AssertExpectations(t) From 4acbfffb517a3bdb64a44cfe47e21b64577aab3e Mon Sep 17 00:00:00 2001 From: riturajFi Date: Sun, 29 Mar 2026 07:56:07 +0530 Subject: [PATCH 11/19] get leaf id as input --- .../src/acontext/resources/async_sessions.py | 16 +++++ .../src/acontext/resources/sessions.py | 16 +++++ .../acontext-py/tests/test_async_client.py | 71 +++++++++++++++++++ src/client/acontext-py/tests/test_client.py | 64 +++++++++++++++++ .../acontext-ts/src/resources/sessions.ts | 22 ++++++ src/client/acontext-ts/tests/client.test.ts | 54 ++++++++++++++ .../go/internal/modules/handler/session.go | 22 +++--- .../internal/modules/handler/session_test.go | 26 +++---- .../go/internal/modules/service/session.go | 16 ++--- .../internal/modules/service/session_test.go | 30 ++++---- 10 files changed, 290 insertions(+), 47 deletions(-) diff --git a/src/client/acontext-py/src/acontext/resources/async_sessions.py b/src/client/acontext-py/src/acontext/resources/async_sessions.py index cfd6a66bf..9587350dc 100644 --- a/src/client/acontext-py/src/acontext/resources/async_sessions.py +++ b/src/client/acontext-py/src/acontext/resources/async_sessions.py @@ -228,6 +228,7 @@ async def store_message( *, blob: MessageBlob, format: Literal["acontext", "openai", "anthropic", "gemini"] = "openai", + parent_id: str | None = None, meta: dict[str, Any] | None = None, file_field: str | None = None, file: ( @@ -243,6 +244,7 @@ async def store_message( session_id: The UUID of the session. blob: The message blob in Acontext, OpenAI, Anthropic, or Gemini format. format: The format of the message blob. Defaults to "openai". + parent_id: Optional parent message UUID for branching. Defaults to None. meta: Optional user-provided metadata for the message. This metadata is stored separately from the message content and can be retrieved via get_messages().metas or updated via patch_message_meta(). Works with all formats. @@ -272,6 +274,8 @@ async def store_message( payload: dict[str, Any] = { "format": format, } + if parent_id is not None: + payload["parent_id"] = parent_id if meta is not None: payload["meta"] = meta @@ -369,6 +373,7 @@ async def get_messages( *, limit: int | None = None, cursor: str | None = None, + leaf_id: str | None = None, with_asset_public_url: bool | None = None, with_events: bool | None = None, format: Literal["acontext", "openai", "anthropic", "gemini"] = "openai", @@ -382,6 +387,7 @@ async def get_messages( session_id: The UUID of the session. limit: Maximum number of messages to return. Defaults to None. cursor: Cursor for pagination. Defaults to None. + leaf_id: Optional leaf message UUID to read one root-to-leaf branch path. Defaults to None. with_asset_public_url: Whether to include presigned URLs for assets. Defaults to None. format: The format of the messages. Defaults to "openai". Supports "acontext", "openai", "anthropic", or "gemini". time_desc: Order by created_at descending if True, ascending if False. Defaults to None. @@ -404,9 +410,19 @@ async def get_messages( Returns: GetMessagesOutput containing the list of messages and pagination information. """ + if leaf_id is not None: + if limit is not None: + raise ValueError("leaf_id cannot be combined with limit") + if cursor is not None: + raise ValueError("leaf_id cannot be combined with cursor") + if time_desc is not None: + raise ValueError("leaf_id cannot be combined with time_desc") + params: dict[str, Any] = {} if format is not None: params["format"] = format + if leaf_id is not None: + params["leaf_id"] = leaf_id params.update( build_params( limit=limit, diff --git a/src/client/acontext-py/src/acontext/resources/sessions.py b/src/client/acontext-py/src/acontext/resources/sessions.py index 083504a48..424747e66 100644 --- a/src/client/acontext-py/src/acontext/resources/sessions.py +++ b/src/client/acontext-py/src/acontext/resources/sessions.py @@ -228,6 +228,7 @@ def store_message( *, blob: MessageBlob, format: Literal["acontext", "openai", "anthropic", "gemini"] = "openai", + parent_id: str | None = None, meta: dict[str, Any] | None = None, file_field: str | None = None, file: ( @@ -243,6 +244,7 @@ def store_message( session_id: The UUID of the session. blob: The message blob in Acontext, OpenAI, Anthropic, or Gemini format. format: The format of the message blob. Defaults to "openai". + parent_id: Optional parent message UUID for branching. Defaults to None. meta: Optional user-provided metadata for the message. This metadata is stored separately from the message content and can be retrieved via get_messages().metas or updated via patch_message_meta(). Works with all formats. @@ -273,6 +275,8 @@ def store_message( payload: dict[str, Any] = { "format": format, } + if parent_id is not None: + payload["parent_id"] = parent_id if meta is not None: payload["meta"] = meta @@ -369,6 +373,7 @@ def get_messages( *, limit: int | None = None, cursor: str | None = None, + leaf_id: str | None = None, with_asset_public_url: bool | None = None, with_events: bool | None = None, format: Literal["acontext", "openai", "anthropic", "gemini"] = "openai", @@ -382,6 +387,7 @@ def get_messages( session_id: The UUID of the session. limit: Maximum number of messages to return. Defaults to None. cursor: Cursor for pagination. Defaults to None. + leaf_id: Optional leaf message UUID to read one root-to-leaf branch path. Defaults to None. with_asset_public_url: Whether to include presigned URLs for assets. Defaults to None. format: The format of the messages. Defaults to "openai". Supports "acontext", "openai", "anthropic", or "gemini". time_desc: Order by created_at descending if True, ascending if False. Defaults to None. @@ -404,9 +410,19 @@ def get_messages( Returns: GetMessagesOutput containing the list of messages and pagination information. """ + if leaf_id is not None: + if limit is not None: + raise ValueError("leaf_id cannot be combined with limit") + if cursor is not None: + raise ValueError("leaf_id cannot be combined with cursor") + if time_desc is not None: + raise ValueError("leaf_id cannot be combined with time_desc") + params: dict[str, Any] = {} if format is not None: params["format"] = format + if leaf_id is not None: + params["leaf_id"] = leaf_id params.update( build_params( limit=limit, diff --git a/src/client/acontext-py/tests/test_async_client.py b/src/client/acontext-py/tests/test_async_client.py index 863bc5552..d36b16af8 100644 --- a/src/client/acontext-py/tests/test_async_client.py +++ b/src/client/acontext-py/tests/test_async_client.py @@ -210,6 +210,38 @@ def read(self) -> bytes: assert attachment[2] == "image/png" +@patch("acontext.async_client.AcontextAsyncClient.request", new_callable=AsyncMock) +@pytest.mark.asyncio +async def test_async_store_message_forwards_parent_id( + mock_request, async_client: AcontextAsyncClient +) -> None: + mock_request.return_value = { + "id": "msg-id", + "session_id": "session-id", + "parent_id": "parent-msg-id", + "role": "user", + "meta": {}, + "parts": [], + "session_task_process_status": "pending", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + + await async_client.sessions.store_message( + "session-id", + blob={"role": "user", "content": "hi"}, # type: ignore[arg-type] + format="openai", + parent_id="parent-msg-id", + ) + + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + method, path = args + assert method == "POST" + assert path == "/session/session-id/messages" + assert kwargs["json_data"]["parent_id"] == "parent-msg-id" + + @patch("acontext.async_client.AcontextAsyncClient.request", new_callable=AsyncMock) @pytest.mark.asyncio async def test_async_store_message_allows_nullable_blob_for_other_formats( @@ -480,6 +512,45 @@ async def test_async_sessions_get_messages_forwards_format( assert hasattr(result, "has_more") +@patch("acontext.async_client.AcontextAsyncClient.request", new_callable=AsyncMock) +@pytest.mark.asyncio +async def test_async_sessions_get_messages_forwards_leaf_id( + mock_request, async_client: AcontextAsyncClient +) -> None: + mock_request.return_value = { + "items": [], + "ids": ["leaf-msg-id"], + "has_more": False, + "this_time_tokens": 0, + } + + result = await async_client.sessions.get_messages( + "session-id", format="acontext", leaf_id="leaf-msg-id" + ) + + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + method, path = args + assert method == "GET" + assert path == "/session/session-id/messages" + assert kwargs["params"] == {"format": "acontext", "leaf_id": "leaf-msg-id"} + assert hasattr(result, "items") + assert hasattr(result, "has_more") + + +@patch("acontext.async_client.AcontextAsyncClient.request", new_callable=AsyncMock) +@pytest.mark.asyncio +async def test_async_sessions_get_messages_rejects_leaf_id_with_limit( + mock_request, async_client: AcontextAsyncClient +) -> None: + with pytest.raises(ValueError, match="leaf_id cannot be combined with limit"): + await async_client.sessions.get_messages( + "session-id", leaf_id="leaf-msg-id", limit=10 + ) + + mock_request.assert_not_called() + + @patch("acontext.async_client.AcontextAsyncClient.request", new_callable=AsyncMock) @pytest.mark.asyncio async def test_async_sessions_get_messages_rejects_non_positive_gt_token( diff --git a/src/client/acontext-py/tests/test_client.py b/src/client/acontext-py/tests/test_client.py index 8cd841ec1..5a0e96217 100644 --- a/src/client/acontext-py/tests/test_client.py +++ b/src/client/acontext-py/tests/test_client.py @@ -143,6 +143,35 @@ def read(self) -> bytes: assert attachment[2] == "image/png" +@patch("acontext.client.AcontextClient.request") +def test_store_message_forwards_parent_id(mock_request, client: AcontextClient) -> None: + mock_request.return_value = { + "id": "msg-id", + "session_id": "session-id", + "parent_id": "parent-msg-id", + "role": "user", + "meta": {}, + "parts": [], + "session_task_process_status": "pending", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + + client.sessions.store_message( + "session-id", + blob={"role": "user", "content": "hi"}, # type: ignore[arg-type] + format="openai", + parent_id="parent-msg-id", + ) + + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + method, path = args + assert method == "POST" + assert path == "/session/session-id/messages" + assert kwargs["json_data"]["parent_id"] == "parent-msg-id" + + @patch("acontext.client.AcontextClient.request") def test_store_message_allows_nullable_blob_for_other_formats( mock_request, client: AcontextClient @@ -553,6 +582,41 @@ def test_sessions_get_messages_forwards_format( assert hasattr(result, "has_more") +@patch("acontext.client.AcontextClient.request") +def test_sessions_get_messages_forwards_leaf_id( + mock_request, client: AcontextClient +) -> None: + mock_request.return_value = { + "items": [], + "ids": ["leaf-msg-id"], + "has_more": False, + "this_time_tokens": 0, + } + + result = client.sessions.get_messages( + "session-id", format="acontext", leaf_id="leaf-msg-id" + ) + + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + method, path = args + assert method == "GET" + assert path == "/session/session-id/messages" + assert kwargs["params"] == {"format": "acontext", "leaf_id": "leaf-msg-id"} + assert hasattr(result, "items") + assert hasattr(result, "has_more") + + +@patch("acontext.client.AcontextClient.request") +def test_sessions_get_messages_rejects_leaf_id_with_limit( + mock_request, client: AcontextClient +) -> None: + with pytest.raises(ValueError, match="leaf_id cannot be combined with limit"): + client.sessions.get_messages("session-id", leaf_id="leaf-msg-id", limit=10) + + mock_request.assert_not_called() + + @patch("acontext.client.AcontextClient.request") def test_sessions_get_messages_with_edit_strategies( mock_request, client: AcontextClient diff --git a/src/client/acontext-ts/src/resources/sessions.ts b/src/client/acontext-ts/src/resources/sessions.ts index 2ff2aacdd..ee87184cf 100644 --- a/src/client/acontext-ts/src/resources/sessions.ts +++ b/src/client/acontext-ts/src/resources/sessions.ts @@ -229,6 +229,7 @@ export class SessionsAPI { blob: MessageBlob, options?: { format?: 'acontext' | 'openai' | 'anthropic' | 'gemini'; + parentId?: string | null; meta?: Record | null; fileField?: string | null; file?: FileUpload | null; @@ -247,6 +248,10 @@ export class SessionsAPI { format, }; + if (options?.parentId !== undefined && options?.parentId !== null) { + payload.parent_id = options.parentId; + } + if (options?.meta !== undefined && options?.meta !== null) { payload.meta = options.meta; } @@ -335,6 +340,7 @@ export class SessionsAPI { * @param options - Options for retrieving messages. * @param options.limit - Maximum number of messages to return. * @param options.cursor - Cursor for pagination. + * @param options.leafId - Return only the root-to-leaf path for this leaf message ID. * @param options.withAssetPublicUrl - Whether to include presigned URLs for assets. * @param options.withEvents - Whether to include session events in the response. * @param options.format - The format of the messages ('acontext', 'openai', 'anthropic', or 'gemini'). @@ -359,6 +365,7 @@ export class SessionsAPI { options?: { limit?: number | null; cursor?: string | null; + leafId?: string | null; withAssetPublicUrl?: boolean | null; withEvents?: boolean | null; format?: 'acontext' | 'openai' | 'anthropic' | 'gemini'; @@ -367,10 +374,25 @@ export class SessionsAPI { pinEditingStrategiesAtMessage?: string | null; } ): Promise { + if (options?.leafId !== undefined && options?.leafId !== null) { + if (options?.limit !== undefined && options?.limit !== null) { + throw new Error('leafId cannot be combined with limit'); + } + if (options?.cursor !== undefined && options?.cursor !== null) { + throw new Error('leafId cannot be combined with cursor'); + } + if (options?.timeDesc !== undefined && options?.timeDesc !== null) { + throw new Error('leafId cannot be combined with timeDesc'); + } + } + const params: Record = {}; if (options?.format !== undefined) { params.format = options.format; } + if (options?.leafId !== undefined && options?.leafId !== null) { + params.leaf_id = options.leafId; + } Object.assign( params, buildParams({ diff --git a/src/client/acontext-ts/tests/client.test.ts b/src/client/acontext-ts/tests/client.test.ts index e4a368282..18489ab13 100644 --- a/src/client/acontext-ts/tests/client.test.ts +++ b/src/client/acontext-ts/tests/client.test.ts @@ -177,6 +177,29 @@ describe('AcontextClient Unit Tests', () => { expect(message.role).toBe('user'); }); + test('should store a message with parentId', async () => { + const sessionId = 'test-session-id'; + const parentId = '123e4567-e89b-12d3-a456-426614174000'; + const storedMessage = mockMessage({ + session_id: sessionId, + parent_id: parentId, + role: 'user', + }); + client.mock().onPost(`/session/${sessionId}/messages`, (options) => { + const data = options?.jsonData as Record; + expect(data?.parent_id).toBe(parentId); + expect(data?.format).toBe('openai'); + return storedMessage; + }); + + const message = await client.sessions.storeMessage( + sessionId, + { role: 'user', content: 'Fork here' }, + { format: 'openai', parentId } + ); + expect(message.parent_id).toBe(parentId); + }); + test('should store message with file upload', async () => { const sessionId = 'test-session-id'; const storedMessage = mockMessage({ session_id: sessionId }); @@ -224,6 +247,37 @@ describe('AcontextClient Unit Tests', () => { expect(result.has_more).toBe(false); }); + test('should get messages with leafId', async () => { + const sessionId = 'test-session-id'; + const leafId = '123e4567-e89b-12d3-a456-426614174001'; + client.mock().onGet(`/session/${sessionId}/messages`, (options) => { + expect(options?.params?.leaf_id).toBe(leafId); + expect(options?.params?.time_desc).toBeUndefined(); + return mockGetMessagesOutput({ + items: [{ role: 'user', content: 'Hello' }], + ids: [leafId], + has_more: false, + this_time_tokens: 10, + }); + }); + + const result = await client.sessions.getMessages(sessionId, { + leafId, + format: 'acontext', + }); + expect(result.ids).toEqual([leafId]); + }); + + test('should reject leafId with timeDesc', async () => { + const sessionId = 'test-session-id'; + await expect( + client.sessions.getMessages(sessionId, { + leafId: '123e4567-e89b-12d3-a456-426614174001', + timeDesc: true, + }) + ).rejects.toThrow('leafId cannot be combined with timeDesc'); + }); + test('should get messages with edit strategies', async () => { const sessionId = 'test-session-id'; const messageId = 'msg-1'; diff --git a/src/server/api/go/internal/modules/handler/session.go b/src/server/api/go/internal/modules/handler/session.go index 35bbce2b6..24ec2df6a 100644 --- a/src/server/api/go/internal/modules/handler/session.go +++ b/src/server/api/go/internal/modules/handler/session.go @@ -506,7 +506,7 @@ func (h *SessionHandler) StoreMessage(c *gin.Context) { type GetMessagesReq struct { Limit *int `form:"limit" json:"limit" binding:"omitempty,min=0,max=200" example:"20"` Cursor string `form:"cursor" json:"cursor" example:"cHJvdGVjdGVkIHZlcnNpb24gdG8gYmUgZXhjbHVkZWQgaW4gcGFyc2luZyB0aGUgY3Vyc29y"` - BranchMessageID string `form:"branch_message_id" json:"branch_message_id" example:""` + LeafID string `form:"leaf_id" json:"leaf_id" example:""` WithAssetPublicURL bool `form:"with_asset_public_url,default=true" json:"with_asset_public_url" example:"true"` WithEvents bool `form:"with_events,default=false" json:"with_events" example:"false"` Format string `form:"format,default=openai" json:"format" binding:"omitempty,oneof=acontext openai anthropic gemini" example:"openai" enums:"acontext,openai,anthropic,gemini"` @@ -525,7 +525,7 @@ type GetMessagesReq struct { // @Param session_id path string true "Session ID" format(uuid) // @Param limit query integer false "Limit of messages to return. Max 200. If limit is 0 or not provided, all messages will be returned. \n\nWARNING!\n Use `limit` only for read-only/display purposes (pagination, viewing). Do NOT use `limit` to truncate messages before sending to LLM as it may cause tool-call and tool-result unpairing issues. Instead, use the `token_limit` edit strategy in `edit_strategies` parameter to safely manage message context size." // @Param cursor query string false "Cursor for pagination. Use the cursor from the previous response to get the next page." -// @Param branch_message_id query string false "Return only the root-to-target branch for this message ID. Cannot be combined with limit, cursor, or time_desc." format(uuid) +// @Param leaf_id query string false "Return only the root-to-leaf path for this leaf message ID. Cannot be combined with limit, cursor, or time_desc." format(uuid) // @Param with_asset_public_url query boolean false "Whether to return asset public url, default is true" example(true) // @Param with_events query boolean false "Whether to include session events in the response, default is false" example(false) // @Param format query string false "Format to convert messages to: acontext (original), openai (default), anthropic, gemini." enums(acontext,openai,anthropic,gemini) @@ -561,27 +561,27 @@ func (h *SessionHandler) GetMessages(c *gin.Context) { limit = *req.Limit } - var branchMessageID *uuid.UUID - if req.BranchMessageID != "" { + var leafID *uuid.UUID + if req.LeafID != "" { if req.Limit != nil { - c.JSON(http.StatusBadRequest, serializer.ParamErr("branch_message_id cannot be combined with limit", nil)) + c.JSON(http.StatusBadRequest, serializer.ParamErr("leaf_id cannot be combined with limit", nil)) return } if _, ok := c.GetQuery("cursor"); ok { - c.JSON(http.StatusBadRequest, serializer.ParamErr("branch_message_id cannot be combined with cursor", nil)) + c.JSON(http.StatusBadRequest, serializer.ParamErr("leaf_id cannot be combined with cursor", nil)) return } if _, ok := c.GetQuery("time_desc"); ok { - c.JSON(http.StatusBadRequest, serializer.ParamErr("branch_message_id cannot be combined with time_desc", nil)) + c.JSON(http.StatusBadRequest, serializer.ParamErr("leaf_id cannot be combined with time_desc", nil)) return } - parsedBranchMessageID, err := uuid.Parse(req.BranchMessageID) + parsedLeafID, err := uuid.Parse(req.LeafID) if err != nil { - c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid branch_message_id", err)) + c.JSON(http.StatusBadRequest, serializer.ParamErr("invalid leaf_id", err)) return } - branchMessageID = &parsedBranchMessageID + leafID = &parsedLeafID } // Parse edit strategies if provided @@ -596,7 +596,7 @@ func (h *SessionHandler) GetMessages(c *gin.Context) { out, err := h.svc.GetMessages(c.Request.Context(), service.GetMessagesInput{ ProjectID: project.ID, SessionID: sessionID, - BranchMessageID: branchMessageID, + LeafID: leafID, Limit: limit, Cursor: req.Cursor, WithAssetPublicURL: req.WithAssetPublicURL, diff --git a/src/server/api/go/internal/modules/handler/session_test.go b/src/server/api/go/internal/modules/handler/session_test.go index bc73987c8..70956b4b5 100644 --- a/src/server/api/go/internal/modules/handler/session_test.go +++ b/src/server/api/go/internal/modules/handler/session_test.go @@ -2088,7 +2088,7 @@ func TestSessionHandler_StoreMessage(t *testing.T) { func TestSessionHandler_GetMessages(t *testing.T) { projectID := uuid.New() sessionID := uuid.New() - branchMessageID := uuid.New() + leafID := uuid.New() tests := []struct { name string @@ -2156,14 +2156,14 @@ func TestSessionHandler_GetMessages(t *testing.T) { expectedStatus: http.StatusBadRequest, }, { - name: "branch_message_id retrieves branch messages", + name: "leaf_id retrieves branch messages", sessionIDParam: sessionID.String(), - queryParams: "?branch_message_id=" + branchMessageID.String(), + queryParams: "?leaf_id=" + leafID.String(), setup: func(svc *MockSessionService) { expectedOutput := &service.GetMessagesOutput{ Items: []model.Message{ { - ID: branchMessageID, + ID: leafID, SessionID: sessionID, Role: model.RoleUser, }, @@ -2171,36 +2171,36 @@ func TestSessionHandler_GetMessages(t *testing.T) { HasMore: false, } svc.On("GetMessages", mock.Anything, mock.MatchedBy(func(in service.GetMessagesInput) bool { - return in.SessionID == sessionID && in.BranchMessageID != nil && *in.BranchMessageID == branchMessageID && in.Limit == 0 + return in.SessionID == sessionID && in.LeafID != nil && *in.LeafID == leafID && in.Limit == 0 })).Return(expectedOutput, nil) }, expectedStatus: http.StatusOK, }, { - name: "invalid branch_message_id", + name: "invalid leaf_id", sessionIDParam: sessionID.String(), - queryParams: "?branch_message_id=not-a-uuid", + queryParams: "?leaf_id=not-a-uuid", setup: func(svc *MockSessionService) {}, expectedStatus: http.StatusBadRequest, }, { - name: "branch_message_id rejects limit", + name: "leaf_id rejects limit", sessionIDParam: sessionID.String(), - queryParams: "?branch_message_id=" + branchMessageID.String() + "&limit=20", + queryParams: "?leaf_id=" + leafID.String() + "&limit=20", setup: func(svc *MockSessionService) {}, expectedStatus: http.StatusBadRequest, }, { - name: "branch_message_id rejects cursor", + name: "leaf_id rejects cursor", sessionIDParam: sessionID.String(), - queryParams: "?branch_message_id=" + branchMessageID.String() + "&cursor=abc", + queryParams: "?leaf_id=" + leafID.String() + "&cursor=abc", setup: func(svc *MockSessionService) {}, expectedStatus: http.StatusBadRequest, }, { - name: "branch_message_id rejects time_desc", + name: "leaf_id rejects time_desc", sessionIDParam: sessionID.String(), - queryParams: "?branch_message_id=" + branchMessageID.String() + "&time_desc=false", + queryParams: "?leaf_id=" + leafID.String() + "&time_desc=false", setup: func(svc *MockSessionService) {}, expectedStatus: http.StatusBadRequest, }, diff --git a/src/server/api/go/internal/modules/service/session.go b/src/server/api/go/internal/modules/service/session.go index 330fd1d06..9705ba781 100644 --- a/src/server/api/go/internal/modules/service/session.go +++ b/src/server/api/go/internal/modules/service/session.go @@ -458,7 +458,7 @@ func (s *sessionService) StoreMessage(ctx context.Context, in StoreMessageInput) type GetMessagesInput struct { ProjectID uuid.UUID `json:"project_id"` SessionID uuid.UUID `json:"session_id"` - BranchMessageID *uuid.UUID `json:"branch_message_id,omitempty"` + LeafID *uuid.UUID `json:"leaf_id,omitempty"` Limit int `json:"limit"` Cursor string `json:"cursor"` WithAssetPublicURL bool `json:"with_public_url"` @@ -576,16 +576,16 @@ func (s *sessionService) GetMessages(ctx context.Context, in GetMessagesInput) ( } var msgs []model.Message - applyBranchWindow := false + applyLeafWindow := false - if in.BranchMessageID != nil { + if in.LeafID != nil { if in.Limit > 0 || in.Cursor != "" || in.TimeDesc { - return nil, fmt.Errorf("branch_message_id cannot be combined with limit, cursor, or time_desc") + return nil, fmt.Errorf("leaf_id cannot be combined with limit, cursor, or time_desc") } - msgs, err = s.sessionRepo.ListMessageBranchPath(ctx, in.SessionID, *in.BranchMessageID) + msgs, err = s.sessionRepo.ListMessageBranchPath(ctx, in.SessionID, *in.LeafID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("branch message not found") + return nil, fmt.Errorf("leaf message not found") } return nil, err } @@ -624,12 +624,12 @@ func (s *sessionService) GetMessages(ctx context.Context, in GetMessagesInput) ( if err != nil { return nil, err } - applyBranchWindow = true + applyLeafWindow = true } } } - if applyBranchWindow { + if applyLeafWindow { msgs, err = windowMessagesForRead(msgs, in.Limit, in.Cursor, in.TimeDesc) if err != nil { return nil, err diff --git a/src/server/api/go/internal/modules/service/session_test.go b/src/server/api/go/internal/modules/service/session_test.go index 47d58beb6..d4a4dcc37 100644 --- a/src/server/api/go/internal/modules/service/session_test.go +++ b/src/server/api/go/internal/modules/service/session_test.go @@ -1492,11 +1492,11 @@ func TestSessionService_GetMessages(t *testing.T) { wantErr: true, }, { - name: "branch_message_id retrieves root-to-target branch path", + name: "leaf_id retrieves root-to-target branch path", input: GetMessagesInput{ - ProjectID: projectID, - SessionID: sessionID, - BranchMessageID: &branchMessageID, + ProjectID: projectID, + SessionID: sessionID, + LeafID: &branchMessageID, }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) @@ -1509,32 +1509,32 @@ func TestSessionService_GetMessages(t *testing.T) { wantErr: false, }, { - name: "branch_message_id with limit returns error", + name: "leaf_id with limit returns error", input: GetMessagesInput{ - ProjectID: projectID, - SessionID: sessionID, - BranchMessageID: &branchMessageID, - Limit: 10, + ProjectID: projectID, + SessionID: sessionID, + LeafID: &branchMessageID, + Limit: 10, }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) }, wantErr: true, - errMsg: "branch_message_id cannot be combined", + errMsg: "leaf_id cannot be combined", }, { - name: "branch_message_id missing target returns not found error", + name: "leaf_id missing target returns not found error", input: GetMessagesInput{ - ProjectID: projectID, - SessionID: sessionID, - BranchMessageID: &branchMessageID, + ProjectID: projectID, + SessionID: sessionID, + LeafID: &branchMessageID, }, setup: func(repo *MockSessionRepo) { repo.On("Get", ctx, mock.MatchedBy(func(s *model.Session) bool { return s.ID == sessionID })).Return(matchSession, nil) repo.On("ListMessageBranchPath", ctx, sessionID, branchMessageID).Return(nil, gorm.ErrRecordNotFound) }, wantErr: true, - errMsg: "branch message not found", + errMsg: "leaf message not found", }, } From b57c833a68e06ebe666a6e1e451594e2aa7095e9 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Thu, 2 Apr 2026 00:55:52 +0530 Subject: [PATCH 12/19] process_inserted_message() now does one branch fetch --- .../service/controller/message.py | 53 ++++--- .../acontext_core/service/data/message.py | 100 ++++++++++-- .../service/test_message_data_branch_path.py | 132 ++++++++++++++++ .../service/test_process_inserted_message.py | 146 ++++++++++-------- 4 files changed, 331 insertions(+), 100 deletions(-) diff --git a/src/server/core/acontext_core/service/controller/message.py b/src/server/core/acontext_core/service/controller/message.py index 6e3c61f5d..67215e7d2 100644 --- a/src/server/core/acontext_core/service/controller/message.py +++ b/src/server/core/acontext_core/service/controller/message.py @@ -1,6 +1,7 @@ from ..data import message as MD from ..data import learning_space as LS from ...infra.db import DB_CLIENT +from ...schema.orm import Message from ...schema.session.task import TaskStatus from ...schema.session.message import MessageBlob from ...schema.utils import asUUID @@ -19,11 +20,11 @@ } -def _claimable_branch_message_ids(message_rows: list[dict]) -> list[asUUID]: +def _claimable_branch_message_ids(messages: list[Message]) -> list[asUUID]: return [ - row["id"] - for row in message_rows - if row["session_task_process_status"] in _CLAIMABLE_BRANCH_STATUSES + message.id + for message in messages + if message.session_task_process_status in _CLAIMABLE_BRANCH_STATUSES ] @@ -50,15 +51,18 @@ async def process_inserted_message( wide = get_wide_event() disabled = await get_metrics(project_id, ExcessMetricTags.new_task_created) target_message_ids: list[asUUID] = [] + branch_messages: list[Message] = [] try: async with DB_CLIENT.get_session_context() as session: - r = await MD.fetch_message_branch_path_rows(session, message_id, session_id) - message_rows, eil = r.unpack() + r = await MD.fetch_message_branch_path_messages( + session, message_id, session_id + ) + branch_messages, eil = r.unpack() if eil: return r - target_message_ids = _claimable_branch_message_ids(message_rows) + target_message_ids = _claimable_branch_message_ids(branch_messages) wide["claimed_branch_message_count"] = len(target_message_ids) if not target_message_ids: @@ -78,25 +82,22 @@ async def process_inserted_message( session, target_message_ids, TaskStatus.RUNNING ) - async with DB_CLIENT.get_session_context() as session: - r = await MD.fetch_message_branch_path_data( - session, message_id, session_id, user_kek=user_kek - ) - messages, eil = r.unpack() - if eil: - await _try_rollback_to_failed(target_message_ids) - return r + r = await MD.hydrate_message_parts(branch_messages, user_kek=user_kek) + messages, eil = r.unpack() + if eil: + await _try_rollback_to_failed(target_message_ids) + return r - messages_data = [ - MessageBlob( - message_id=m.id, - parent_id=m.parent_id, - role=m.role, - parts=m.parts, - task_id=m.task_id, - ) - for m in messages - ] + messages_data = [ + MessageBlob( + message_id=m.id, + parent_id=m.parent_id, + role=m.role, + parts=m.parts, + task_id=m.task_id, + ) + for m in messages + ] async with DB_CLIENT.get_session_context() as session: r = await LS.get_learning_space_for_session(session, session_id) @@ -139,6 +140,8 @@ async def process_inserted_message( wide["task_agent_outcome"] = "exception" await _try_rollback_to_failed(target_message_ids) raise + + async def process_session_pending_message( project_config: ProjectConfig, project_id: asUUID, diff --git a/src/server/core/acontext_core/service/data/message.py b/src/server/core/acontext_core/service/data/message.py index d41534ecd..e838a14bd 100644 --- a/src/server/core/acontext_core/service/data/message.py +++ b/src/server/core/acontext_core/service/data/message.py @@ -109,28 +109,41 @@ async def fetch_messages_data_by_ids( f"Some messages({message_ids}) not found in database: {e}" ) - if not ordered_messages: + return await hydrate_message_parts(ordered_messages, user_kek=user_kek) + + except Exception as e: + return Result.reject(f"Error fetching messages by IDs {message_ids}: {e}") + + +async def hydrate_message_parts( + messages: List[Message], + user_kek: bytes | None = None, +) -> Result[List[Message]]: + """ + Load parts from S3 for already-fetched message rows. + + Mutates each message in place by setting `message.parts`. + """ + try: + if not messages: return Result.resolve([]) - # Fetch parts concurrently for all messages parts_tasks = [ _fetch_message_parts(message.parts_asset_meta, user_kek=user_kek) - for message in ordered_messages + for message in messages ] parts_results = await asyncio.gather(*parts_tasks) - # Assign parts to messages - for message, parts_result in zip(ordered_messages, parts_results): + for message, parts_result in zip(messages, parts_results): d, eil = parts_result.unpack() if eil: message.parts = None continue message.parts = d - return Result.resolve(ordered_messages) - + return Result.resolve(messages) except Exception as e: - return Result.reject(f"Error fetching messages by IDs {message_ids}: {e}") + return Result.reject(f"Error hydrating message parts: {e}") async def fetch_message_branch_path_ids( @@ -226,6 +239,69 @@ async def fetch_message_branch_path_rows( ) +async def fetch_message_branch_path_messages( + db_session: AsyncSession, + message_id: asUUID, + session_id: asUUID | None = None, +) -> Result[List[Message]]: + """ + Fetch one message's branch path as ordered Message rows. + + Uses a recursive CTE to walk parent_id upward in one query. + """ + try: + query = text( + """ + WITH RECURSIVE message_path AS ( + SELECT id, parent_id, session_id, 0 AS depth + FROM messages + WHERE id = :message_id + + UNION ALL + + SELECT + parent.id, + parent.parent_id, + parent.session_id, + child.depth + 1 AS depth + FROM messages AS parent + JOIN message_path AS child + ON parent.id = child.parent_id + ) + SELECT m.* + FROM message_path AS mp + JOIN messages AS m ON m.id = mp.id + ORDER BY mp.depth DESC, m.id ASC + """ + ) + result = await db_session.execute( + select(Message).from_statement(query), + {"message_id": message_id}, + ) + messages = list(result.scalars().all()) + + if not messages: + return Result.reject(f"Message {message_id} doesn't exist") + + path_session_ids = {message.session_id for message in messages} + + if session_id is not None and path_session_ids != {session_id}: + return Result.reject( + f"Message {message_id} does not belong to session {session_id}" + ) + + if len(path_session_ids) != 1: + return Result.reject( + f"Message {message_id} has an invalid cross-session parent chain" + ) + + return Result.resolve(messages) + except Exception as e: + return Result.reject( + f"Error fetching branch path messages for message {message_id}: {e}" + ) + + async def branch_pending_message_length( db_session: AsyncSession, message_id: asUUID, @@ -267,13 +343,11 @@ async def fetch_message_branch_path_data( """ Fetch one message's branch path with parts loaded from S3. """ - r = await fetch_message_branch_path_ids(db_session, message_id, session_id) - message_ids, eil = r.unpack() + r = await fetch_message_branch_path_messages(db_session, message_id, session_id) + messages, eil = r.unpack() if eil: return Result.reject(str(eil)) - return await fetch_messages_data_by_ids( - db_session, message_ids, user_kek=user_kek - ) + return await hydrate_message_parts(messages, user_kek=user_kek) async def fetch_session_messages( diff --git a/src/server/core/tests/service/test_message_data_branch_path.py b/src/server/core/tests/service/test_message_data_branch_path.py index 254d2f6d7..c3ee94ad7 100644 --- a/src/server/core/tests/service/test_message_data_branch_path.py +++ b/src/server/core/tests/service/test_message_data_branch_path.py @@ -1,5 +1,7 @@ +import json import pytest from uuid import uuid4 +from unittest.mock import AsyncMock, patch from acontext_core.schema.orm import Message, Project, Session from acontext_core.service.data import message as MD @@ -167,6 +169,136 @@ async def test_fetch_message_branch_path_rows_returns_statuses(db_client): ] +@pytest.mark.asyncio +async def test_fetch_message_branch_path_messages_returns_ordered_messages(db_client): + _, acontext_session = await _create_project_and_session(db_client) + + async with db_client.get_session_context() as session: + root = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={ + "bucket": "test-bucket", + "s3_key": "parts/root.json", + "etag": "etag-root", + "sha256": "sha-root", + "mime": "application/json", + "size_b": 10, + }, + session_task_process_status="success", + ) + session.add(root) + await session.flush() + + child = Message( + session_id=acontext_session.id, + role="assistant", + parts_asset_meta={ + "bucket": "test-bucket", + "s3_key": "parts/child.json", + "etag": "etag-child", + "sha256": "sha-child", + "mime": "application/json", + "size_b": 11, + }, + parent_id=root.id, + session_task_process_status="pending", + ) + session.add(child) + await session.flush() + + leaf = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={ + "bucket": "test-bucket", + "s3_key": "parts/leaf.json", + "etag": "etag-leaf", + "sha256": "sha-leaf", + "mime": "application/json", + "size_b": 12, + }, + parent_id=child.id, + session_task_process_status="running", + ) + session.add(leaf) + await session.flush() + + r = await MD.fetch_message_branch_path_messages( + session, leaf.id, acontext_session.id + ) + messages, eil = r.unpack() + + assert eil is None + assert [message.id for message in messages] == [root.id, child.id, leaf.id] + assert [message.session_task_process_status for message in messages] == [ + "success", + "pending", + "running", + ] + + +@pytest.mark.asyncio +async def test_hydrate_message_parts_uses_existing_branch_message_order(db_client): + _, acontext_session = await _create_project_and_session(db_client) + + async with db_client.get_session_context() as session: + root = Message( + session_id=acontext_session.id, + role="user", + parts_asset_meta={ + "bucket": "test-bucket", + "s3_key": "parts/root.json", + "etag": "etag-root", + "sha256": "sha-root", + "mime": "application/json", + "size_b": 10, + }, + ) + session.add(root) + await session.flush() + + leaf = Message( + session_id=acontext_session.id, + role="assistant", + parts_asset_meta={ + "bucket": "test-bucket", + "s3_key": "parts/leaf.json", + "etag": "etag-leaf", + "sha256": "sha-leaf", + "mime": "application/json", + "size_b": 11, + }, + parent_id=root.id, + ) + session.add(leaf) + await session.flush() + + branch_result = await MD.fetch_message_branch_path_messages( + session, leaf.id, acontext_session.id + ) + messages, eil = branch_result.unpack() + + assert eil is None + + with patch( + "acontext_core.service.data.message.S3_CLIENT.download_object", + new_callable=AsyncMock, + side_effect=[ + json.dumps([{"type": "text", "text": "root"}]).encode("utf-8"), + json.dumps([{"type": "text", "text": "leaf"}]).encode("utf-8"), + ], + ) as mock_download: + hydrated_result = await MD.hydrate_message_parts(messages) + hydrated_messages, hydrate_error = hydrated_result.unpack() + + assert hydrate_error is None + assert [message.id for message in hydrated_messages] == [root.id, leaf.id] + assert hydrated_messages[0].parts[0].text == "root" + assert hydrated_messages[1].parts[0].text == "leaf" + assert mock_download.await_count == 2 + + @pytest.mark.asyncio async def test_branch_pending_message_length_ignores_sibling_branches(db_client): _, acontext_session = await _create_project_and_session(db_client) diff --git a/src/server/core/tests/service/test_process_inserted_message.py b/src/server/core/tests/service/test_process_inserted_message.py index 073652803..20442fb4b 100644 --- a/src/server/core/tests/service/test_process_inserted_message.py +++ b/src/server/core/tests/service/test_process_inserted_message.py @@ -43,29 +43,22 @@ def _mock_project_config(): return config -def _make_message(message_id: uuid.UUID, role: str, parent_id: uuid.UUID | None = None): +def _make_message( + message_id: uuid.UUID, + role: str, + parent_id: uuid.UUID | None = None, + status: str = TaskStatus.PENDING.value, +): message = MagicMock() message.id = message_id message.parent_id = parent_id message.role = role message.parts = [] message.task_id = None + message.session_task_process_status = status return message -def _branch_row( - message_id: uuid.UUID, - status: str, - parent_id: uuid.UUID | None = None, -): - return { - "id": message_id, - "parent_id": parent_id, - "session_id": _SESSION_ID, - "session_task_process_status": status, - } - - class TestProcessInsertedMessage: @pytest.mark.asyncio async def test_marks_pending_branch_suffix_running_then_success(self): @@ -73,14 +66,19 @@ async def test_marks_pending_branch_suffix_running_then_success(self): root_id = uuid.uuid4() branch_id = uuid.uuid4() branch_messages = [ - _make_message(root_id, "user"), - _make_message(branch_id, "assistant", parent_id=root_id), - _make_message(_MESSAGE_ID, "user", parent_id=branch_id), - ] - branch_rows = [ - _branch_row(root_id, TaskStatus.SUCCESS.value), - _branch_row(branch_id, TaskStatus.PENDING.value, parent_id=root_id), - _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=branch_id), + _make_message(root_id, "user", status=TaskStatus.SUCCESS.value), + _make_message( + branch_id, + "assistant", + parent_id=root_id, + status=TaskStatus.PENDING.value, + ), + _make_message( + _MESSAGE_ID, + "user", + parent_id=branch_id, + status=TaskStatus.PENDING.value, + ), ] with ( @@ -88,16 +86,16 @@ async def test_marks_pending_branch_suffix_running_then_success(self): patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), patch( - f"{MODULE}.MD.fetch_message_branch_path_rows", + f"{MODULE}.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve(branch_rows), + return_value=Result.resolve(branch_messages), ), patch( f"{MODULE}.MD.update_message_status_to", update_status, ), patch( - f"{MODULE}.MD.fetch_message_branch_path_data", + f"{MODULE}.MD.hydrate_message_parts", new_callable=AsyncMock, return_value=Result.resolve(branch_messages), ), @@ -147,12 +145,13 @@ async def test_forwards_user_kek_to_branch_path_fetch(self): update_status = AsyncMock() root_id = uuid.uuid4() branch_messages = [ - _make_message(root_id, "user"), - _make_message(_MESSAGE_ID, "assistant", parent_id=root_id), - ] - branch_rows = [ - _branch_row(root_id, TaskStatus.SUCCESS.value), - _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=root_id), + _make_message(root_id, "user", status=TaskStatus.SUCCESS.value), + _make_message( + _MESSAGE_ID, + "assistant", + parent_id=root_id, + status=TaskStatus.PENDING.value, + ), ] with ( @@ -160,13 +159,13 @@ async def test_forwards_user_kek_to_branch_path_fetch(self): patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), patch( - f"{MODULE}.MD.fetch_message_branch_path_rows", + f"{MODULE}.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve(branch_rows), + return_value=Result.resolve(branch_messages), ), patch(f"{MODULE}.MD.update_message_status_to", update_status), patch( - f"{MODULE}.MD.fetch_message_branch_path_data", + f"{MODULE}.MD.hydrate_message_parts", new_callable=AsyncMock, return_value=Result.resolve(branch_messages), ) as mock_fetch_branch, @@ -191,9 +190,7 @@ async def test_forwards_user_kek_to_branch_path_fetch(self): assert result.ok() mock_fetch_branch.assert_called_once_with( - pytest.ANY, - _MESSAGE_ID, - _SESSION_ID, + branch_messages, user_kek=b"secret-kek", ) @@ -202,10 +199,20 @@ async def test_project_disabled_marks_pending_branch_suffix_limit_exceed(self): update_status = AsyncMock() root_id = uuid.uuid4() branch_id = uuid.uuid4() - branch_rows = [ - _branch_row(root_id, TaskStatus.SUCCESS.value), - _branch_row(branch_id, TaskStatus.PENDING.value, parent_id=root_id), - _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=branch_id), + branch_messages = [ + _make_message(root_id, "user", status=TaskStatus.SUCCESS.value), + _make_message( + branch_id, + "assistant", + parent_id=root_id, + status=TaskStatus.PENDING.value, + ), + _make_message( + _MESSAGE_ID, + "user", + parent_id=branch_id, + status=TaskStatus.PENDING.value, + ), ] with ( @@ -213,16 +220,16 @@ async def test_project_disabled_marks_pending_branch_suffix_limit_exceed(self): patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=True), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), patch( - f"{MODULE}.MD.fetch_message_branch_path_rows", + f"{MODULE}.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve(branch_rows), + return_value=Result.resolve(branch_messages), ), patch( f"{MODULE}.MD.update_message_status_to", update_status, ), patch( - f"{MODULE}.MD.fetch_message_branch_path_data", + f"{MODULE}.MD.hydrate_message_parts", new_callable=AsyncMock, ) as mock_branch_data, patch( @@ -256,10 +263,20 @@ async def test_branch_load_error_rolls_back_pending_branch_suffix(self): update_status = AsyncMock() root_id = uuid.uuid4() branch_id = uuid.uuid4() - branch_rows = [ - _branch_row(root_id, TaskStatus.SUCCESS.value), - _branch_row(branch_id, TaskStatus.PENDING.value, parent_id=root_id), - _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=branch_id), + branch_messages = [ + _make_message(root_id, "user", status=TaskStatus.SUCCESS.value), + _make_message( + branch_id, + "assistant", + parent_id=root_id, + status=TaskStatus.PENDING.value, + ), + _make_message( + _MESSAGE_ID, + "user", + parent_id=branch_id, + status=TaskStatus.PENDING.value, + ), ] with ( @@ -267,16 +284,16 @@ async def test_branch_load_error_rolls_back_pending_branch_suffix(self): patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), patch( - f"{MODULE}.MD.fetch_message_branch_path_rows", + f"{MODULE}.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve(branch_rows), + return_value=Result.resolve(branch_messages), ), patch( f"{MODULE}.MD.update_message_status_to", update_status, ), patch( - f"{MODULE}.MD.fetch_message_branch_path_data", + f"{MODULE}.MD.hydrate_message_parts", new_callable=AsyncMock, return_value=Result.reject("branch lookup failed"), ), @@ -314,14 +331,19 @@ async def test_task_agent_exception_rolls_back_pending_branch_suffix(self): root_id = uuid.uuid4() branch_id = uuid.uuid4() branch_messages = [ - _make_message(root_id, "user"), - _make_message(branch_id, "assistant", parent_id=root_id), - _make_message(_MESSAGE_ID, "user", parent_id=branch_id), - ] - branch_rows = [ - _branch_row(root_id, TaskStatus.SUCCESS.value), - _branch_row(branch_id, TaskStatus.RUNNING.value, parent_id=root_id), - _branch_row(_MESSAGE_ID, TaskStatus.PENDING.value, parent_id=branch_id), + _make_message(root_id, "user", status=TaskStatus.SUCCESS.value), + _make_message( + branch_id, + "assistant", + parent_id=root_id, + status=TaskStatus.RUNNING.value, + ), + _make_message( + _MESSAGE_ID, + "user", + parent_id=branch_id, + status=TaskStatus.PENDING.value, + ), ] with ( @@ -329,16 +351,16 @@ async def test_task_agent_exception_rolls_back_pending_branch_suffix(self): patch(f"{MODULE}.get_metrics", new_callable=AsyncMock, return_value=False), patch(f"{MODULE}.get_wide_event", MagicMock(return_value={})), patch( - f"{MODULE}.MD.fetch_message_branch_path_rows", + f"{MODULE}.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve(branch_rows), + return_value=Result.resolve(branch_messages), ), patch( f"{MODULE}.MD.update_message_status_to", update_status, ), patch( - f"{MODULE}.MD.fetch_message_branch_path_data", + f"{MODULE}.MD.hydrate_message_parts", new_callable=AsyncMock, return_value=Result.resolve(branch_messages), ), From e38a402ac3488a7b7313b5eb0b7e3d018d88d9bd Mon Sep 17 00:00:00 2001 From: riturajFi Date: Thu, 2 Apr 2026 03:21:18 +0530 Subject: [PATCH 13/19] Removed the dead helpers. --- .../acontext_core/service/data/message.py | 41 ------------------- .../service/test_message_data_branch_path.py | 24 +++++------ 2 files changed, 12 insertions(+), 53 deletions(-) diff --git a/src/server/core/acontext_core/service/data/message.py b/src/server/core/acontext_core/service/data/message.py index e838a14bd..fe74ade93 100644 --- a/src/server/core/acontext_core/service/data/message.py +++ b/src/server/core/acontext_core/service/data/message.py @@ -146,31 +146,6 @@ async def hydrate_message_parts( return Result.reject(f"Error hydrating message parts: {e}") -async def fetch_message_branch_path_ids( - db_session: AsyncSession, - message_id: asUUID, - session_id: asUUID | None = None, -) -> Result[List[asUUID]]: - """ - Fetch one message's branch path from root to the target message. - - Uses a recursive CTE to walk parent_id upward in one query. - - Args: - db_session: Database session - message_id: Leaf or intermediate message UUID to start from - session_id: Optional session UUID to verify the full path belongs to - - Returns: - Result containing message IDs ordered from root to target message - """ - r = await fetch_message_branch_path_rows(db_session, message_id, session_id) - rows, eil = r.unpack() - if eil: - return Result.reject(str(eil)) - return Result.resolve([row["id"] for row in rows]) - - async def fetch_message_branch_path_rows( db_session: AsyncSession, message_id: asUUID, @@ -334,22 +309,6 @@ async def branch_pending_message_length( ) -async def fetch_message_branch_path_data( - db_session: AsyncSession, - message_id: asUUID, - session_id: asUUID | None = None, - user_kek: bytes | None = None, -) -> Result[List[Message]]: - """ - Fetch one message's branch path with parts loaded from S3. - """ - r = await fetch_message_branch_path_messages(db_session, message_id, session_id) - messages, eil = r.unpack() - if eil: - return Result.reject(str(eil)) - return await hydrate_message_parts(messages, user_kek=user_kek) - - async def fetch_session_messages( db_session: AsyncSession, session_id: asUUID, diff --git a/src/server/core/tests/service/test_message_data_branch_path.py b/src/server/core/tests/service/test_message_data_branch_path.py index c3ee94ad7..603857529 100644 --- a/src/server/core/tests/service/test_message_data_branch_path.py +++ b/src/server/core/tests/service/test_message_data_branch_path.py @@ -25,7 +25,7 @@ async def _create_project_and_session(db_client): @pytest.mark.asyncio -async def test_fetch_message_branch_path_ids_root_only(db_client): +async def test_fetch_message_branch_path_rows_root_only(db_client): _, acontext_session = await _create_project_and_session(db_client) async with db_client.get_session_context() as session: @@ -37,17 +37,17 @@ async def test_fetch_message_branch_path_ids_root_only(db_client): session.add(root) await session.flush() - r = await MD.fetch_message_branch_path_ids( + r = await MD.fetch_message_branch_path_rows( session, root.id, acontext_session.id ) - message_ids, eil = r.unpack() + rows, eil = r.unpack() assert eil is None - assert message_ids == [root.id] + assert [row["id"] for row in rows] == [root.id] @pytest.mark.asyncio -async def test_fetch_message_branch_path_ids_returns_root_to_leaf_order(db_client): +async def test_fetch_message_branch_path_rows_returns_root_to_leaf_order(db_client): _, acontext_session = await _create_project_and_session(db_client) async with db_client.get_session_context() as session: @@ -77,17 +77,17 @@ async def test_fetch_message_branch_path_ids_returns_root_to_leaf_order(db_clien session.add(leaf) await session.flush() - r = await MD.fetch_message_branch_path_ids( + r = await MD.fetch_message_branch_path_rows( session, leaf.id, acontext_session.id ) - message_ids, eil = r.unpack() + rows, eil = r.unpack() assert eil is None - assert message_ids == [root.id, child.id, leaf.id] + assert [row["id"] for row in rows] == [root.id, child.id, leaf.id] @pytest.mark.asyncio -async def test_fetch_message_branch_path_ids_rejects_wrong_session(db_client): +async def test_fetch_message_branch_path_rows_rejects_wrong_session(db_client): _, session_a = await _create_project_and_session(db_client) _, session_b = await _create_project_and_session(db_client) @@ -100,7 +100,7 @@ async def test_fetch_message_branch_path_ids_rejects_wrong_session(db_client): session.add(msg) await session.flush() - r = await MD.fetch_message_branch_path_ids(session, msg.id, session_b.id) + r = await MD.fetch_message_branch_path_rows(session, msg.id, session_b.id) _, eil = r.unpack() assert eil is not None @@ -108,11 +108,11 @@ async def test_fetch_message_branch_path_ids_rejects_wrong_session(db_client): @pytest.mark.asyncio -async def test_fetch_message_branch_path_ids_rejects_missing_message(db_client): +async def test_fetch_message_branch_path_rows_rejects_missing_message(db_client): _, acontext_session = await _create_project_and_session(db_client) async with db_client.get_session_context() as session: - r = await MD.fetch_message_branch_path_ids( + r = await MD.fetch_message_branch_path_rows( session, acontext_session.id, acontext_session.id ) _, eil = r.unpack() From 5e4bb6e4c419caf916648f235728f6aabf5063b0 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Thu, 2 Apr 2026 03:30:46 +0530 Subject: [PATCH 14/19] fetch_message_branch_path_rows() is removed --- .../acontext_core/service/data/message.py | 80 ++-------------- .../acontext_core/service/session_message.py | 25 ++--- .../tests/service/test_buffer_timer_dedup.py | 49 +++++----- .../core/tests/service/test_flush_retries.py | 44 ++++----- .../service/test_message_data_branch_path.py | 95 +++---------------- 5 files changed, 82 insertions(+), 211 deletions(-) diff --git a/src/server/core/acontext_core/service/data/message.py b/src/server/core/acontext_core/service/data/message.py index fe74ade93..664aa1bc9 100644 --- a/src/server/core/acontext_core/service/data/message.py +++ b/src/server/core/acontext_core/service/data/message.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import List, Any +from typing import List from sqlalchemy import select, func, text from sqlalchemy.ext.asyncio import AsyncSession from pydantic import ValidationError @@ -146,74 +146,6 @@ async def hydrate_message_parts( return Result.reject(f"Error hydrating message parts: {e}") -async def fetch_message_branch_path_rows( - db_session: AsyncSession, - message_id: asUUID, - session_id: asUUID | None = None, -) -> Result[list[dict[str, Any]]]: - """ - Fetch one message's branch path from root to the target message. - - Uses a recursive CTE to walk parent_id upward in one query. - - Args: - db_session: Database session - message_id: Leaf or intermediate message UUID to start from - session_id: Optional session UUID to verify the full path belongs to - - Returns: - Result containing branch path rows ordered from root to target message - """ - try: - query = text( - """ - WITH RECURSIVE message_path AS ( - SELECT id, parent_id, session_id, session_task_process_status, 0 AS depth - FROM messages - WHERE id = :message_id - - UNION ALL - - SELECT - parent.id, - parent.parent_id, - parent.session_id, - parent.session_task_process_status, - child.depth + 1 AS depth - FROM messages AS parent - JOIN message_path AS child - ON parent.id = child.parent_id - ) - SELECT id, parent_id, session_id, session_task_process_status, depth - FROM message_path - ORDER BY depth DESC, id ASC - """ - ) - result = await db_session.execute(query, {"message_id": message_id}) - rows = [dict(row) for row in result.mappings().all()] - - if not rows: - return Result.reject(f"Message {message_id} doesn't exist") - - path_session_ids = {row["session_id"] for row in rows} - - if session_id is not None and path_session_ids != {session_id}: - return Result.reject( - f"Message {message_id} does not belong to session {session_id}" - ) - - if len(path_session_ids) != 1: - return Result.reject( - f"Message {message_id} has an invalid cross-session parent chain" - ) - - return Result.resolve(rows) - except Exception as e: - return Result.reject( - f"Error fetching branch path for message {message_id}: {e}" - ) - - async def fetch_message_branch_path_messages( db_session: AsyncSession, message_id: asUUID, @@ -296,12 +228,16 @@ async def branch_pending_message_length( Result containing the count of matching messages on the branch path """ try: - r = await fetch_message_branch_path_rows(db_session, message_id, session_id) - rows, eil = r.unpack() + r = await fetch_message_branch_path_messages(db_session, message_id, session_id) + messages, eil = r.unpack() if eil: return Result.reject(str(eil)) - count = sum(1 for row in rows if row["session_task_process_status"] == status) + count = sum( + 1 + for message in messages + if message.session_task_process_status == status + ) return Result.resolve(count) except Exception as e: return Result.reject( diff --git a/src/server/core/acontext_core/service/session_message.py b/src/server/core/acontext_core/service/session_message.py index b693d1376..d04c2118b 100644 --- a/src/server/core/acontext_core/service/session_message.py +++ b/src/server/core/acontext_core/service/session_message.py @@ -13,6 +13,7 @@ from ..schema.mq.session import InsertNewMessage from ..schema.utils import asUUID from ..schema.result import Result +from ..schema.orm import Message as ORMMessage from ..schema.session.learning_space import SessionStatus from ..schema.session.task import TaskStatus from .constants import EX, RK @@ -38,12 +39,12 @@ def _insert_message_lock_key(session_id: asUUID, message_id: asUUID) -> str: } -def _branch_lock_message_id(message_rows: list[dict]) -> asUUID: +def _branch_lock_message_id(messages: list[ORMMessage]) -> asUUID: # Lock on the first unfinished message so all descendants contend on one key. - for row in message_rows: - if row["session_task_process_status"] not in _BRANCH_LOCK_TERMINAL_STATUSES: - return row["id"] - return message_rows[-1]["id"] + for message in messages: + if message.session_task_process_status not in _BRANCH_LOCK_TERMINAL_STATUSES: + return message.id + return messages[-1].id async def _get_pending_session_message_ids(session_id: asUUID) -> Result[list[asUUID]]: @@ -99,12 +100,14 @@ async def insert_new_message(body: InsertNewMessage, message: Message): return async with DB_CLIENT.get_session_context() as session: - r = await MD.fetch_message_branch_path_rows(session, body.message_id, body.session_id) - message_rows, eil = r.unpack() + r = await MD.fetch_message_branch_path_messages( + session, body.message_id, body.session_id + ) + branch_messages, eil = r.unpack() if eil: return - lock_message_id = _branch_lock_message_id(message_rows) + lock_message_id = _branch_lock_message_id(branch_messages) lock_key = _insert_message_lock_key(body.session_id, lock_message_id) _l = await check_redis_lock_or_set(body.project_id, lock_key) if not _l: @@ -241,16 +244,16 @@ async def flush_session_message_blocking( if msg_status != "pending": continue - r = await MD.fetch_message_branch_path_rows( + r = await MD.fetch_message_branch_path_messages( read_session, message_id, session_id ) - message_rows, eil = r.unpack() + branch_messages, eil = r.unpack() if eil: wide_event["outcome"] = "error" wide_event["error"] = str(eil) return r - lock_message_id = _branch_lock_message_id(message_rows) + lock_message_id = _branch_lock_message_id(branch_messages) lock_key = _insert_message_lock_key(session_id, lock_message_id) _l = await check_redis_lock_or_set(project_id, lock_key) if not _l: diff --git a/src/server/core/tests/service/test_buffer_timer_dedup.py b/src/server/core/tests/service/test_buffer_timer_dedup.py index ebde99d5a..04494ad5a 100644 --- a/src/server/core/tests/service/test_buffer_timer_dedup.py +++ b/src/server/core/tests/service/test_buffer_timer_dedup.py @@ -47,14 +47,13 @@ def _mock_db(): return mock_db -def _branch_row(message_id, session_id, status="pending", parent_id=None, depth=0): - return { - "id": message_id, - "parent_id": parent_id, - "session_id": session_id, - "session_task_process_status": status, - "depth": depth, - } +def _branch_message(message_id, session_id, status="pending", parent_id=None): + message = MagicMock() + message.id = message_id + message.parent_id = parent_id + message.session_id = session_id + message.session_task_process_status = status + return message class TestMessageNotPending: @@ -227,9 +226,11 @@ async def test_processes_immediately_when_at_threshold(self): return_value=Result.resolve(16), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve([_branch_row(body.message_id, body.session_id)]), + return_value=Result.resolve( + [_branch_message(body.message_id, body.session_id)] + ), ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", @@ -278,9 +279,11 @@ async def test_processes_when_message_still_pending(self): return_value=Result.resolve(2), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve([_branch_row(body.message_id, body.session_id)]), + return_value=Result.resolve( + [_branch_message(body.message_id, body.session_id)] + ), ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", @@ -411,9 +414,11 @@ async def capture_publish(**kwargs): return_value=Result.resolve(2), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve([_branch_row(body.message_id, body.session_id)]), + return_value=Result.resolve( + [_branch_message(body.message_id, body.session_id)] + ), ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", @@ -458,9 +463,11 @@ async def capture_publish(**kwargs): return_value=Result.resolve(20), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve([_branch_row(body.message_id, body.session_id)]), + return_value=Result.resolve( + [_branch_message(body.message_id, body.session_id)] + ), ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", @@ -585,24 +592,22 @@ async def test_retry_uses_branch_boundary_lock_key(self): return_value=Result.resolve(16), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, return_value=Result.resolve( [ - _branch_row(root_id, body.session_id, status="success", depth=0), - _branch_row( + _branch_message(root_id, body.session_id, status="success"), + _branch_message( branch_id, body.session_id, status="pending", parent_id=root_id, - depth=1, ), - _branch_row( + _branch_message( body.message_id, body.session_id, status="pending", parent_id=branch_id, - depth=2, ), ] ), diff --git a/src/server/core/tests/service/test_flush_retries.py b/src/server/core/tests/service/test_flush_retries.py index 9cc0bfd53..7793694c1 100644 --- a/src/server/core/tests/service/test_flush_retries.py +++ b/src/server/core/tests/service/test_flush_retries.py @@ -25,14 +25,13 @@ def _mock_db(): return mock_db -def _branch_row(message_id, session_id, status="pending", parent_id=None, depth=0): - return { - "id": message_id, - "parent_id": parent_id, - "session_id": session_id, - "session_task_process_status": status, - "depth": depth, - } +def _branch_message(message_id, session_id, status="pending", parent_id=None): + message = MagicMock() + message.id = message_id + message.parent_id = parent_id + message.session_id = session_id + message.session_task_process_status = status + return message class TestFlushBranchAwareProcessing: @@ -64,11 +63,11 @@ async def test_processes_each_pending_message_individually(self): return_value=Result.resolve("pending"), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, side_effect=[ - Result.resolve([_branch_row(message_a, session_id)]), - Result.resolve([_branch_row(message_b, session_id)]), + Result.resolve([_branch_message(message_a, session_id)]), + Result.resolve([_branch_message(message_b, session_id)]), ], ), patch( @@ -130,9 +129,9 @@ async def test_retries_when_branch_lock_is_busy_then_processes(self): return_value=Result.resolve("pending"), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve([_branch_row(message_id, session_id)]), + return_value=Result.resolve([_branch_message(message_id, session_id)]), ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", @@ -190,9 +189,9 @@ async def test_returns_reject_after_max_retries_when_branch_locks_stay_busy(self return_value=Result.resolve("pending"), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve([_branch_row(message_id, session_id)]), + return_value=Result.resolve([_branch_message(message_id, session_id)]), ), patch( "acontext_core.service.session_message.check_redis_lock_or_set", @@ -252,37 +251,34 @@ async def test_same_branch_messages_share_one_lock_boundary(self): return_value=Result.resolve("pending"), ), patch( - "acontext_core.service.session_message.MD.fetch_message_branch_path_rows", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, side_effect=[ Result.resolve( [ - _branch_row(root_id, session_id, status="success", depth=0), - _branch_row( + _branch_message(root_id, session_id, status="success"), + _branch_message( branch_id, session_id, status="pending", parent_id=root_id, - depth=1, ), ] ), Result.resolve( [ - _branch_row(root_id, session_id, status="success", depth=0), - _branch_row( + _branch_message(root_id, session_id, status="success"), + _branch_message( branch_id, session_id, status="pending", parent_id=root_id, - depth=1, ), - _branch_row( + _branch_message( leaf_id, session_id, status="pending", parent_id=branch_id, - depth=2, ), ] ), diff --git a/src/server/core/tests/service/test_message_data_branch_path.py b/src/server/core/tests/service/test_message_data_branch_path.py index 603857529..4151a6a36 100644 --- a/src/server/core/tests/service/test_message_data_branch_path.py +++ b/src/server/core/tests/service/test_message_data_branch_path.py @@ -25,7 +25,7 @@ async def _create_project_and_session(db_client): @pytest.mark.asyncio -async def test_fetch_message_branch_path_rows_root_only(db_client): +async def test_fetch_message_branch_path_messages_root_only(db_client): _, acontext_session = await _create_project_and_session(db_client) async with db_client.get_session_context() as session: @@ -37,17 +37,17 @@ async def test_fetch_message_branch_path_rows_root_only(db_client): session.add(root) await session.flush() - r = await MD.fetch_message_branch_path_rows( + r = await MD.fetch_message_branch_path_messages( session, root.id, acontext_session.id ) - rows, eil = r.unpack() + messages, eil = r.unpack() assert eil is None - assert [row["id"] for row in rows] == [root.id] + assert [message.id for message in messages] == [root.id] @pytest.mark.asyncio -async def test_fetch_message_branch_path_rows_returns_root_to_leaf_order(db_client): +async def test_fetch_message_branch_path_messages_returns_root_to_leaf_order(db_client): _, acontext_session = await _create_project_and_session(db_client) async with db_client.get_session_context() as session: @@ -77,17 +77,17 @@ async def test_fetch_message_branch_path_rows_returns_root_to_leaf_order(db_clie session.add(leaf) await session.flush() - r = await MD.fetch_message_branch_path_rows( + r = await MD.fetch_message_branch_path_messages( session, leaf.id, acontext_session.id ) - rows, eil = r.unpack() + messages, eil = r.unpack() assert eil is None - assert [row["id"] for row in rows] == [root.id, child.id, leaf.id] + assert [message.id for message in messages] == [root.id, child.id, leaf.id] @pytest.mark.asyncio -async def test_fetch_message_branch_path_rows_rejects_wrong_session(db_client): +async def test_fetch_message_branch_path_messages_rejects_wrong_session(db_client): _, session_a = await _create_project_and_session(db_client) _, session_b = await _create_project_and_session(db_client) @@ -100,7 +100,7 @@ async def test_fetch_message_branch_path_rows_rejects_wrong_session(db_client): session.add(msg) await session.flush() - r = await MD.fetch_message_branch_path_rows(session, msg.id, session_b.id) + r = await MD.fetch_message_branch_path_messages(session, msg.id, session_b.id) _, eil = r.unpack() assert eil is not None @@ -108,11 +108,11 @@ async def test_fetch_message_branch_path_rows_rejects_wrong_session(db_client): @pytest.mark.asyncio -async def test_fetch_message_branch_path_rows_rejects_missing_message(db_client): +async def test_fetch_message_branch_path_messages_rejects_missing_message(db_client): _, acontext_session = await _create_project_and_session(db_client) async with db_client.get_session_context() as session: - r = await MD.fetch_message_branch_path_rows( + r = await MD.fetch_message_branch_path_messages( session, acontext_session.id, acontext_session.id ) _, eil = r.unpack() @@ -122,7 +122,7 @@ async def test_fetch_message_branch_path_rows_rejects_missing_message(db_client) @pytest.mark.asyncio -async def test_fetch_message_branch_path_rows_returns_statuses(db_client): +async def test_fetch_message_branch_path_messages_returns_statuses(db_client): _, acontext_session = await _create_project_and_session(db_client) async with db_client.get_session_context() as session: @@ -155,75 +155,6 @@ async def test_fetch_message_branch_path_rows_returns_statuses(db_client): session.add(leaf) await session.flush() - r = await MD.fetch_message_branch_path_rows( - session, leaf.id, acontext_session.id - ) - rows, eil = r.unpack() - - assert eil is None - assert [row["id"] for row in rows] == [root.id, child.id, leaf.id] - assert [row["session_task_process_status"] for row in rows] == [ - "success", - "pending", - "running", - ] - - -@pytest.mark.asyncio -async def test_fetch_message_branch_path_messages_returns_ordered_messages(db_client): - _, acontext_session = await _create_project_and_session(db_client) - - async with db_client.get_session_context() as session: - root = Message( - session_id=acontext_session.id, - role="user", - parts_asset_meta={ - "bucket": "test-bucket", - "s3_key": "parts/root.json", - "etag": "etag-root", - "sha256": "sha-root", - "mime": "application/json", - "size_b": 10, - }, - session_task_process_status="success", - ) - session.add(root) - await session.flush() - - child = Message( - session_id=acontext_session.id, - role="assistant", - parts_asset_meta={ - "bucket": "test-bucket", - "s3_key": "parts/child.json", - "etag": "etag-child", - "sha256": "sha-child", - "mime": "application/json", - "size_b": 11, - }, - parent_id=root.id, - session_task_process_status="pending", - ) - session.add(child) - await session.flush() - - leaf = Message( - session_id=acontext_session.id, - role="user", - parts_asset_meta={ - "bucket": "test-bucket", - "s3_key": "parts/leaf.json", - "etag": "etag-leaf", - "sha256": "sha-leaf", - "mime": "application/json", - "size_b": 12, - }, - parent_id=child.id, - session_task_process_status="running", - ) - session.add(leaf) - await session.flush() - r = await MD.fetch_message_branch_path_messages( session, leaf.id, acontext_session.id ) From ec07511812af9c4dfe97d2b309328d5b0536a6d8 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Thu, 2 Apr 2026 12:35:04 +0530 Subject: [PATCH 15/19] sanitation --- .../go/internal/modules/handler/session.go | 1 + .../go/internal/modules/handler/task_test.go | 11 ++ .../go/internal/modules/service/session.go | 1 + .../core/acontext_core/llm/agent/task.py | 11 +- .../acontext_core/llm/tool/task_lib/ctx.py | 4 +- .../acontext_core/service/data/message.py | 36 ------- .../acontext_core/service/session_message.py | 80 ++++++++------ src/server/core/tests/llm/test_task_agent.py | 5 +- .../tests/service/test_buffer_timer_dedup.py | 100 +++++++++--------- .../service/test_message_data_branch_path.py | 53 ---------- src/server/core/uv.lock | 2 +- 11 files changed, 117 insertions(+), 187 deletions(-) diff --git a/src/server/api/go/internal/modules/handler/session.go b/src/server/api/go/internal/modules/handler/session.go index 24ec2df6a..8e31363c2 100644 --- a/src/server/api/go/internal/modules/handler/session.go +++ b/src/server/api/go/internal/modules/handler/session.go @@ -563,6 +563,7 @@ func (h *SessionHandler) GetMessages(c *gin.Context) { var leafID *uuid.UUID if req.LeafID != "" { + // TODO: Deduplicate this validation with the service-side check while keeping the early HTTP error path. if req.Limit != nil { c.JSON(http.StatusBadRequest, serializer.ParamErr("leaf_id cannot be combined with limit", nil)) return diff --git a/src/server/api/go/internal/modules/handler/task_test.go b/src/server/api/go/internal/modules/handler/task_test.go index 630b01348..c94f59b5f 100644 --- a/src/server/api/go/internal/modules/handler/task_test.go +++ b/src/server/api/go/internal/modules/handler/task_test.go @@ -73,6 +73,10 @@ func (m *MockSessionRepo) ListAllMessagesBySession(ctx context.Context, sessionI args := m.Called(ctx, sessionID) return args.Get(0).([]model.Message), args.Error(1) } +func (m *MockSessionRepo) ListMessageBranchPath(ctx context.Context, sessionID uuid.UUID, messageID uuid.UUID) ([]model.Message, error) { + args := m.Called(ctx, sessionID, messageID) + return args.Get(0).([]model.Message), args.Error(1) +} func (m *MockSessionRepo) GetObservingStatus(ctx context.Context, sessionID string) (*model.MessageObservingStatus, error) { args := m.Called(ctx, sessionID) if args.Get(0) == nil { @@ -91,6 +95,13 @@ func (m *MockSessionRepo) GetMessageByID(ctx context.Context, sessionID uuid.UUI } return args.Get(0).(*model.Message), args.Error(1) } +func (m *MockSessionRepo) GetMessageByIDAnySession(ctx context.Context, messageID uuid.UUID) (*model.Message, error) { + args := m.Called(ctx, messageID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Message), args.Error(1) +} func (m *MockSessionRepo) UpdateMessageMeta(ctx context.Context, messageID uuid.UUID, meta datatypes.JSONType[map[string]interface{}]) error { return m.Called(ctx, messageID, meta).Error(0) } diff --git a/src/server/api/go/internal/modules/service/session.go b/src/server/api/go/internal/modules/service/session.go index 9705ba781..6e73c8310 100644 --- a/src/server/api/go/internal/modules/service/session.go +++ b/src/server/api/go/internal/modules/service/session.go @@ -579,6 +579,7 @@ func (s *sessionService) GetMessages(ctx context.Context, in GetMessagesInput) ( applyLeafWindow := false if in.LeafID != nil { + // TODO: Deduplicate this validation with the handler-side check without removing service-level defense. if in.Limit > 0 || in.Cursor != "" || in.TimeDesc { return nil, fmt.Errorf("leaf_id cannot be combined with limit, cursor, or time_desc") } diff --git a/src/server/core/acontext_core/llm/agent/task.py b/src/server/core/acontext_core/llm/agent/task.py index 42b1a8a4d..eaf432f12 100644 --- a/src/server/core/acontext_core/llm/agent/task.py +++ b/src/server/core/acontext_core/llm/agent/task.py @@ -111,17 +111,13 @@ async def build_task_ctx( messages: list[MessageBlob], before_use_ctx: TaskCtx = None, ) -> TaskCtx: + # Task tooling only needs ordered message IDs for the active branch. message_ids_index = [m.message_id for m in messages] - message_parent_ids_index = [m.parent_id for m in messages] - branch_root_message_id = messages[0].message_id if messages else None - branch_leaf_message_id = messages[-1].message_id if messages else None if before_use_ctx is not None: + # Refresh the reused context with the current branch index only. before_use_ctx.db_session = db_session before_use_ctx.message_ids_index = message_ids_index - before_use_ctx.message_parent_ids_index = message_parent_ids_index - before_use_ctx.branch_root_message_id = branch_root_message_id - before_use_ctx.branch_leaf_message_id = branch_leaf_message_id return before_use_ctx r = await TD.fetch_current_tasks(db_session, session_id) @@ -135,9 +131,6 @@ async def build_task_ctx( task_ids_index=[t.id for t in current_tasks], task_index=current_tasks, message_ids_index=message_ids_index, - message_parent_ids_index=message_parent_ids_index, - branch_root_message_id=branch_root_message_id, - branch_leaf_message_id=branch_leaf_message_id, ) return use_ctx diff --git a/src/server/core/acontext_core/llm/tool/task_lib/ctx.py b/src/server/core/acontext_core/llm/tool/task_lib/ctx.py index 46f329c51..880d3eb2f 100644 --- a/src/server/core/acontext_core/llm/tool/task_lib/ctx.py +++ b/src/server/core/acontext_core/llm/tool/task_lib/ctx.py @@ -11,9 +11,7 @@ class TaskCtx: session_id: asUUID task_ids_index: list[asUUID] task_index: list[TaskSchema] + # Only keep the message index that production task code reads today. message_ids_index: list[asUUID] - message_parent_ids_index: list[asUUID | None] = field(default_factory=list) - branch_root_message_id: asUUID | None = None - branch_leaf_message_id: asUUID | None = None learning_task_ids: list[asUUID] = field(default_factory=list) pending_preferences: list[str] = field(default_factory=list) diff --git a/src/server/core/acontext_core/service/data/message.py b/src/server/core/acontext_core/service/data/message.py index 664aa1bc9..3af4c5d88 100644 --- a/src/server/core/acontext_core/service/data/message.py +++ b/src/server/core/acontext_core/service/data/message.py @@ -209,42 +209,6 @@ async def fetch_message_branch_path_messages( ) -async def branch_pending_message_length( - db_session: AsyncSession, - message_id: asUUID, - status: str = "pending", - session_id: asUUID | None = None, -) -> Result[int]: - """ - Count pending messages on one message's branch path from root to target. - - Args: - db_session: Database session - message_id: Target message UUID - status: Status filter for messages on the branch - session_id: Optional session UUID to verify the full path belongs to - - Returns: - Result containing the count of matching messages on the branch path - """ - try: - r = await fetch_message_branch_path_messages(db_session, message_id, session_id) - messages, eil = r.unpack() - if eil: - return Result.reject(str(eil)) - - count = sum( - 1 - for message in messages - if message.session_task_process_status == status - ) - return Result.resolve(count) - except Exception as e: - return Result.reject( - f"Error counting branch messages for message {message_id}: {e}" - ) - - async def fetch_session_messages( db_session: AsyncSession, session_id: asUUID, diff --git a/src/server/core/acontext_core/service/session_message.py b/src/server/core/acontext_core/service/session_message.py index d04c2118b..d67a04f4f 100644 --- a/src/server/core/acontext_core/service/session_message.py +++ b/src/server/core/acontext_core/service/session_message.py @@ -47,6 +47,38 @@ def _branch_lock_message_id(messages: list[ORMMessage]) -> asUUID: return messages[-1].id +def _pending_branch_message_count( + messages: list[ORMMessage], + status: str = TaskStatus.PENDING.value, +) -> int: + # Count from the already-fetched branch so we do not query the same path twice. + return sum(1 for message in messages if message.session_task_process_status == status) + + +async def _prepare_pending_branch_lock( + db_session, + session_id: asUUID, + message_id: asUUID, +) -> Result[tuple[list[ORMMessage], str] | None]: + # Both insert and flush need the same three things: + # verify the target is still pending, load the branch once, and derive one lock key. + r = await MD.check_session_message_status(db_session, message_id) + msg_status, eil = r.unpack() + if eil: + return r + if msg_status != TaskStatus.PENDING.value: + return Result.resolve(None) + + r = await MD.fetch_message_branch_path_messages(db_session, message_id, session_id) + branch_messages, eil = r.unpack() + if eil: + return r + + lock_message_id = _branch_lock_message_id(branch_messages) + lock_key = _insert_message_lock_key(session_id, lock_message_id) + return Result.resolve((branch_messages, lock_key)) + + async def _get_pending_session_message_ids(session_id: asUUID) -> Result[list[asUUID]]: async with DB_CLIENT.get_session_context() as session: return await MD.get_message_ids(session, session_id, limit=None, asc=True) @@ -64,24 +96,23 @@ async def insert_new_message(body: InsertNewMessage, message: Message): wide = get_wide_event() async with DB_CLIENT.get_session_context() as session: - r = await MD.check_session_message_status(session, body.message_id) - msg_status, eil = r.unpack() - if eil or msg_status != "pending": + # Reuse one branch fetch for both pending-count logic and lock-key selection. + r = await _prepare_pending_branch_lock(session, body.session_id, body.message_id) + prepared, eil = r.unpack() + if eil: + return + if prepared is None: wide["action"] = "skip_not_pending" wide["_log_level"] = "debug" return + branch_messages, lock_key = prepared r = await PD.get_project_config(session, body.project_id) project_config, eil = r.unpack() if eil: return - r = await MD.branch_pending_message_length( - session, body.message_id, session_id=body.session_id - ) - pending_count, eil = r.unpack() - if eil: - return + pending_count = _pending_branch_message_count(branch_messages) wide["pending_branch_message_count"] = pending_count @@ -99,16 +130,6 @@ async def insert_new_message(body: InsertNewMessage, message: Message): ) return - async with DB_CLIENT.get_session_context() as session: - r = await MD.fetch_message_branch_path_messages( - session, body.message_id, body.session_id - ) - branch_messages, eil = r.unpack() - if eil: - return - - lock_message_id = _branch_lock_message_id(branch_messages) - lock_key = _insert_message_lock_key(body.session_id, lock_message_id) _l = await check_redis_lock_or_set(body.project_id, lock_key) if not _l: wide["lock_acquired"] = False @@ -235,26 +256,19 @@ async def flush_session_message_blocking( for message_id in pending_message_ids: async with DB_CLIENT.get_session_context() as read_session: - r = await MD.check_session_message_status(read_session, message_id) - msg_status, eil = r.unpack() - if eil: - wide_event["outcome"] = "error" - wide_event["error"] = str(eil) - return r - if msg_status != "pending": - continue - - r = await MD.fetch_message_branch_path_messages( - read_session, message_id, session_id + # Reuse the same helper as insert_new_message() so both paths lock branches the same way. + r = await _prepare_pending_branch_lock( + read_session, session_id, message_id ) - branch_messages, eil = r.unpack() + prepared, eil = r.unpack() if eil: wide_event["outcome"] = "error" wide_event["error"] = str(eil) return r + if prepared is None: + continue + _, lock_key = prepared - lock_message_id = _branch_lock_message_id(branch_messages) - lock_key = _insert_message_lock_key(session_id, lock_message_id) _l = await check_redis_lock_or_set(project_id, lock_key) if not _l: busy_lock_count += 1 diff --git a/src/server/core/tests/llm/test_task_agent.py b/src/server/core/tests/llm/test_task_agent.py index 865c53457..c021afda7 100644 --- a/src/server/core/tests/llm/test_task_agent.py +++ b/src/server/core/tests/llm/test_task_agent.py @@ -520,7 +520,7 @@ def test_includes_branch_context_section(self): class TestBuildTaskCtxBranchMetadata: @pytest.mark.asyncio - async def test_sets_branch_root_leaf_and_parent_indexes(self): + async def test_sets_message_id_index(self): db_session = AsyncMock() project_id = uuid.uuid4() session_id = uuid.uuid4() @@ -549,9 +549,6 @@ async def test_sets_branch_root_leaf_and_parent_indexes(self): ctx = await build_task_ctx(db_session, project_id, session_id, messages) assert ctx.message_ids_index == [root_id, leaf_id] - assert ctx.message_parent_ids_index == [None, root_id] - assert ctx.branch_root_message_id == root_id - assert ctx.branch_leaf_message_id == leaf_id # ============================================================================= diff --git a/src/server/core/tests/service/test_buffer_timer_dedup.py b/src/server/core/tests/service/test_buffer_timer_dedup.py index 04494ad5a..a3f2e6d3c 100644 --- a/src/server/core/tests/service/test_buffer_timer_dedup.py +++ b/src/server/core/tests/service/test_buffer_timer_dedup.py @@ -56,6 +56,23 @@ def _branch_message(message_id, session_id, status="pending", parent_id=None): return message +def _pending_branch_chain(message_id, session_id, count): + ids = [message_id] + [uuid.uuid4() for _ in range(max(0, count - 1))] + messages = [] + parent_id = None + for branch_message_id in ids: + messages.append( + _branch_message( + branch_message_id, + session_id, + status="pending", + parent_id=parent_id, + ) + ) + parent_id = branch_message_id + return messages + + class TestMessageNotPending: @pytest.mark.asyncio async def test_skips_when_message_already_processed(self): @@ -147,9 +164,11 @@ async def capture_publish(**kwargs): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve(3), + return_value=Result.resolve( + _pending_branch_chain(body.message_id, body.session_id, 3) + ), ), patch("acontext_core.service.session_message.publish_mq", side_effect=capture_publish), ): @@ -185,9 +204,11 @@ async def capture_publish(**kwargs): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve(3), + return_value=Result.resolve( + _pending_branch_chain(body1.message_id, session_id, 3) + ), ), patch("acontext_core.service.session_message.publish_mq", side_effect=capture_publish), ): @@ -220,16 +241,11 @@ async def test_processes_immediately_when_at_threshold(self): new_callable=AsyncMock, return_value=Result.resolve(project_config), ), - patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", - new_callable=AsyncMock, - return_value=Result.resolve(16), - ), patch( "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, return_value=Result.resolve( - [_branch_message(body.message_id, body.session_id)] + _pending_branch_chain(body.message_id, body.session_id, 16) ), ), patch( @@ -273,16 +289,11 @@ async def test_processes_when_message_still_pending(self): new_callable=AsyncMock, return_value=Result.resolve(project_config), ), - patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", - new_callable=AsyncMock, - return_value=Result.resolve(2), - ), patch( "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, return_value=Result.resolve( - [_branch_message(body.message_id, body.session_id)] + _pending_branch_chain(body.message_id, body.session_id, 2) ), ), patch( @@ -321,14 +332,16 @@ async def test_processes_with_decoded_user_kek(self): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", + "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, - return_value=Result.resolve(2), + return_value=True, ), patch( - "acontext_core.service.session_message.check_redis_lock_or_set", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=True, + return_value=Result.resolve( + _pending_branch_chain(body.message_id, body.session_id, 2) + ), ), patch("acontext_core.service.session_message.release_redis_lock", new_callable=AsyncMock), patch( @@ -361,14 +374,16 @@ async def test_invalid_user_kek_marks_session_failed(self): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", + "acontext_core.service.session_message.check_redis_lock_or_set", new_callable=AsyncMock, - return_value=Result.resolve(2), + return_value=True, ), patch( - "acontext_core.service.session_message.check_redis_lock_or_set", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=True, + return_value=Result.resolve( + _pending_branch_chain(body.message_id, body.session_id, 2) + ), ), patch("acontext_core.service.session_message.release_redis_lock", new_callable=AsyncMock), patch( @@ -408,16 +423,11 @@ async def capture_publish(**kwargs): new_callable=AsyncMock, return_value=Result.resolve(project_config), ), - patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", - new_callable=AsyncMock, - return_value=Result.resolve(2), - ), patch( "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, return_value=Result.resolve( - [_branch_message(body.message_id, body.session_id)] + _pending_branch_chain(body.message_id, body.session_id, 2) ), ), patch( @@ -457,16 +467,11 @@ async def capture_publish(**kwargs): new_callable=AsyncMock, return_value=Result.resolve(project_config), ), - patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", - new_callable=AsyncMock, - return_value=Result.resolve(20), - ), patch( "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, return_value=Result.resolve( - [_branch_message(body.message_id, body.session_id)] + _pending_branch_chain(body.message_id, body.session_id, 20) ), ), patch( @@ -509,9 +514,11 @@ async def capture_publish(**kwargs): return_value=Result.resolve(project_config), ), patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, - return_value=Result.resolve(3), + return_value=Result.resolve( + _pending_branch_chain(body1.message_id, body1.session_id, 3) + ), ), patch("acontext_core.service.session_message.publish_mq", side_effect=capture_publish), ): @@ -547,15 +554,17 @@ async def capture_publish(**kwargs): new_callable=AsyncMock, return_value=Result.resolve(project_config), ), - patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", - new_callable=AsyncMock, - return_value=Result.resolve(1), - ), patch( "acontext_core.service.session_message.MD.session_message_length", new_callable=AsyncMock, ) as mock_session_count, + patch( + "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", + new_callable=AsyncMock, + return_value=Result.resolve( + _pending_branch_chain(body.message_id, body.session_id, 1) + ), + ), patch("acontext_core.service.session_message.publish_mq", side_effect=capture_publish), ): await insert_new_message(body, MagicMock()) @@ -586,11 +595,6 @@ async def test_retry_uses_branch_boundary_lock_key(self): new_callable=AsyncMock, return_value=Result.resolve(project_config), ), - patch( - "acontext_core.service.session_message.MD.branch_pending_message_length", - new_callable=AsyncMock, - return_value=Result.resolve(16), - ), patch( "acontext_core.service.session_message.MD.fetch_message_branch_path_messages", new_callable=AsyncMock, diff --git a/src/server/core/tests/service/test_message_data_branch_path.py b/src/server/core/tests/service/test_message_data_branch_path.py index 4151a6a36..ba35a74c3 100644 --- a/src/server/core/tests/service/test_message_data_branch_path.py +++ b/src/server/core/tests/service/test_message_data_branch_path.py @@ -228,56 +228,3 @@ async def test_hydrate_message_parts_uses_existing_branch_message_order(db_clien assert hydrated_messages[0].parts[0].text == "root" assert hydrated_messages[1].parts[0].text == "leaf" assert mock_download.await_count == 2 - - -@pytest.mark.asyncio -async def test_branch_pending_message_length_ignores_sibling_branches(db_client): - _, acontext_session = await _create_project_and_session(db_client) - - async with db_client.get_session_context() as session: - root = Message( - session_id=acontext_session.id, - role="user", - parts_asset_meta={}, - session_task_process_status="success", - ) - session.add(root) - await session.flush() - - branch_a = Message( - session_id=acontext_session.id, - role="assistant", - parts_asset_meta={}, - parent_id=root.id, - session_task_process_status="pending", - ) - session.add(branch_a) - await session.flush() - - branch_b = Message( - session_id=acontext_session.id, - role="assistant", - parts_asset_meta={}, - parent_id=root.id, - session_task_process_status="pending", - ) - session.add(branch_b) - await session.flush() - - leaf_a = Message( - session_id=acontext_session.id, - role="user", - parts_asset_meta={}, - parent_id=branch_a.id, - session_task_process_status="pending", - ) - session.add(leaf_a) - await session.flush() - - r = await MD.branch_pending_message_length( - session, leaf_a.id, session_id=acontext_session.id - ) - pending_count, eil = r.unpack() - - assert eil is None - assert pending_count == 2 diff --git a/src/server/core/uv.lock b/src/server/core/uv.lock index 2052cd464..47fa2667c 100644 --- a/src/server/core/uv.lock +++ b/src/server/core/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "acontext-core" -version = "0.1.18" +version = "0.1.20" source = { virtual = "." } dependencies = [ { name = "aio-pika" }, From 5a4c7dc7f0f06f344249e9790345cf365ca5be74 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Thu, 2 Apr 2026 15:06:48 +0530 Subject: [PATCH 16/19] test fixes --- .../go/internal/modules/handler/task_test.go | 2 ++ .../service/controller/skill_learner.py | 23 ++++++++++++++----- .../tests/llm/test_skill_learner_prompt.py | 3 ++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/server/api/go/internal/modules/handler/task_test.go b/src/server/api/go/internal/modules/handler/task_test.go index c94f59b5f..8a2cf8135 100644 --- a/src/server/api/go/internal/modules/handler/task_test.go +++ b/src/server/api/go/internal/modules/handler/task_test.go @@ -38,6 +38,8 @@ type MockSessionRepo struct { mock.Mock } +var _ repo.SessionRepo = (*MockSessionRepo)(nil) + func (m *MockSessionRepo) Create(ctx context.Context, s *model.Session) error { return m.Called(ctx, s).Error(0) } diff --git a/src/server/core/acontext_core/service/controller/skill_learner.py b/src/server/core/acontext_core/service/controller/skill_learner.py index c28087d46..caf9eebcc 100644 --- a/src/server/core/acontext_core/service/controller/skill_learner.py +++ b/src/server/core/acontext_core/service/controller/skill_learner.py @@ -25,6 +25,17 @@ from ...llm.agent.skill_learner import skill_learner_agent +def _coerce_optional_uuid(value): + if value is None: + return None + if isinstance(value, UUID): + return value + try: + return UUID(str(value)) + except (TypeError, ValueError, AttributeError): + return None + + async def process_context_distillation( project_id: asUUID, session_id: asUUID, @@ -72,12 +83,12 @@ async def process_context_distillation( messages, eil = r.unpack() if not eil and messages: task_messages = [ - MessageBlob( - message_id=m.id, - parent_id=m.parent_id, - role=m.role, - parts=m.parts, - task_id=m.task_id, + MessageBlob( + message_id=m.id, + parent_id=_coerce_optional_uuid(m.parent_id), + role=m.role, + parts=m.parts, + task_id=m.task_id, ) for m in messages ] diff --git a/src/server/core/tests/llm/test_skill_learner_prompt.py b/src/server/core/tests/llm/test_skill_learner_prompt.py index 91dc9d0d6..7cd701155 100644 --- a/src/server/core/tests/llm/test_skill_learner_prompt.py +++ b/src/server/core/tests/llm/test_skill_learner_prompt.py @@ -11,6 +11,7 @@ """ import uuid +from datetime import date import pytest from acontext_core.llm.prompt.skill_learner import SkillLearnerPrompt from acontext_core.schema.mq.learning import SkillLearnDistilled @@ -172,7 +173,7 @@ def test_uses_each_context_own_original_date(self): # Each context should have its own date assert "Date: 2023/05/21" in result assert "Date: 2024/01/15" in result - assert "Date: 2026-04-01" in result # today's date fallback + assert f"Date: {date.today().isoformat()}" in result # today's date fallback # Should NOT have a single "Today's date" at the end assert "Today's date:" not in result From 6a5f20e6a14ef6e8bf85dde9b0ba37e05f073513 Mon Sep 17 00:00:00 2001 From: riturajFi Date: Thu, 2 Apr 2026 15:18:19 +0530 Subject: [PATCH 17/19] version bump --- landingpage/package.json | 2 +- landingpage/pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/landingpage/package.json b/landingpage/package.json index cf0b9cb46..4822ea675 100644 --- a/landingpage/package.json +++ b/landingpage/package.json @@ -87,7 +87,7 @@ "workerd" ], "overrides": { - "lodash": ">=4.17.23", + "lodash": ">=4.18.0", "ajv@<6.14.0": "6.14.0", "ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0", "minimatch@>=8.0.0 <10.2.3": "10.2.3", diff --git a/landingpage/pnpm-lock.yaml b/landingpage/pnpm-lock.yaml index d9da468e4..62f8edf1a 100644 --- a/landingpage/pnpm-lock.yaml +++ b/landingpage/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - lodash: '>=4.17.23' + lodash: '>=4.18.0' ajv@<6.14.0: 6.14.0 ajv@>=7.0.0-alpha.0 <8.18.0: 8.18.0 minimatch@>=8.0.0 <10.2.3: 10.2.3 @@ -4518,7 +4518,7 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.23: + lodash@4.18.0: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} longest-streak@3.1.0: @@ -10920,7 +10920,7 @@ snapshots: '@types/lodash': 4.17.24 is-glob: 4.0.3 js-yaml: 4.1.1 - lodash: 4.17.23 + lodash: 4.18.0 minimist: 1.2.8 prettier: 3.8.1 tinyglobby: 0.2.15 @@ -11028,7 +11028,7 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.17.23: {} + lodash@4.18.0: {} longest-streak@3.1.0: {} From bf7ef45ade3e4be053cdc71feae44255cf30a4dd Mon Sep 17 00:00:00 2001 From: riturajFi Date: Thu, 2 Apr 2026 15:33:48 +0530 Subject: [PATCH 18/19] version bump --- src/server/core/pyproject.toml | 1 + src/server/core/uv.lock | 178 +++++++++++++++++---------------- 2 files changed, 91 insertions(+), 88 deletions(-) diff --git a/src/server/core/pyproject.toml b/src/server/core/pyproject.toml index 8203b68aa..4aafb5a89 100644 --- a/src/server/core/pyproject.toml +++ b/src/server/core/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "boto3>=1.42.19", "cryptography>=46.0.6", "pygments>=2.20.0", + "aiohttp>=3.13.4", ] [dependency-groups] diff --git a/src/server/core/uv.lock b/src/server/core/uv.lock index 47fa2667c..1fab6e662 100644 --- a/src/server/core/uv.lock +++ b/src/server/core/uv.lock @@ -14,6 +14,7 @@ dependencies = [ { name = "aio-pika" }, { name = "aiobotocore" }, { name = "aiobotocore-otel" }, + { name = "aiohttp" }, { name = "anthropic" }, { name = "asyncpg" }, { name = "boto3" }, @@ -59,6 +60,7 @@ requires-dist = [ { name = "aio-pika", specifier = ">=9.5.7" }, { name = "aiobotocore", specifier = ">=2.24.2" }, { name = "aiobotocore-otel", specifier = ">=1.1.0" }, + { name = "aiohttp", specifier = ">=3.13.4" }, { name = "anthropic", specifier = ">=0.67.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "boto3", specifier = ">=1.42.19" }, @@ -157,7 +159,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -168,93 +170,93 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] [[package]] From 5d4a4206fd3ccc4c71a269aae920ec481c8dbf7f Mon Sep 17 00:00:00 2001 From: riturajFi Date: Thu, 2 Apr 2026 16:04:07 +0530 Subject: [PATCH 19/19] version bump --- landingpage/package.json | 2 +- landingpage/pnpm-lock.yaml | 63 +++----------------------------------- 2 files changed, 6 insertions(+), 59 deletions(-) diff --git a/landingpage/package.json b/landingpage/package.json index 4822ea675..f3cc69de1 100644 --- a/landingpage/package.json +++ b/landingpage/package.json @@ -87,7 +87,7 @@ "workerd" ], "overrides": { - "lodash": ">=4.18.0", + "lodash": "4.18.1", "ajv@<6.14.0": "6.14.0", "ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0", "minimatch@>=8.0.0 <10.2.3": "10.2.3", diff --git a/landingpage/pnpm-lock.yaml b/landingpage/pnpm-lock.yaml index 62f8edf1a..05281731a 100644 --- a/landingpage/pnpm-lock.yaml +++ b/landingpage/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - lodash: '>=4.18.0' + lodash: 4.18.1 ajv@<6.14.0: 6.14.0 ajv@>=7.0.0-alpha.0 <8.18.0: 8.18.0 minimatch@>=8.0.0 <10.2.3: 10.2.3 @@ -188,28 +188,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-arm64-musl@0.40.5': resolution: {integrity: sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@ast-grep/napi-linux-x64-gnu@0.40.5': resolution: {integrity: sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-x64-musl@0.40.5': resolution: {integrity: sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@ast-grep/napi-win32-arm64-msvc@0.40.5': resolution: {integrity: sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug==} @@ -1574,105 +1570,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1878,28 +1858,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.5.14': resolution: {integrity: sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.5.14': resolution: {integrity: sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.5.14': resolution: {integrity: sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.5.14': resolution: {integrity: sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==} @@ -2124,79 +2100,66 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -2515,28 +2478,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -2798,49 +2757,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4469,28 +4420,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4518,8 +4465,8 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.18.0: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -10920,7 +10867,7 @@ snapshots: '@types/lodash': 4.17.24 is-glob: 4.0.3 js-yaml: 4.1.1 - lodash: 4.18.0 + lodash: 4.18.1 minimist: 1.2.8 prettier: 3.8.1 tinyglobby: 0.2.15 @@ -11028,7 +10975,7 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.18.0: {} + lodash@4.18.1: {} longest-streak@3.1.0: {}