From 8f63fb27ba25862a1b754451d00408c855610638 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Mon, 24 Aug 2026 16:56:01 -0400 Subject: [PATCH] [agents] Fork completed agent session scrobbles on follow-up --- tests/agents_tests/test_agent_sessions.py | 81 ++++++++++++++++++- vrobbler/apps/agents/models.py | 1 + vrobbler/apps/scrobbles/scrobblers.py | 47 ++++++++++- vrobbler/apps/scrobbles/tasks.py | 2 +- vrobbler/apps/scrobbles/views.py | 6 +- vrobbler/settings.py | 3 + .../templates/scrobbles/scrobble_detail.html | 5 ++ 7 files changed, 135 insertions(+), 10 deletions(-) diff --git a/tests/agents_tests/test_agent_sessions.py b/tests/agents_tests/test_agent_sessions.py index db12c74..4e78704 100644 --- a/tests/agents_tests/test_agent_sessions.py +++ b/tests/agents_tests/test_agent_sessions.py @@ -1105,8 +1105,8 @@ def test_agent_session_detail_view(mock_delay, client, user): @patch("scrobbles.tasks.scrobble_agent_session_prompt.delay") -def test_manual_scrobble_agent_follow_up_appends_turn(mock_delay, user): - scrobble = _mk_scrobble( +def test_manual_scrobble_agent_follow_up_forks_completed_session(mock_delay, user): + original = _mk_scrobble( user, in_progress=False, turns=[ @@ -1118,6 +1118,42 @@ def test_manual_scrobble_agent_follow_up_appends_turn(mock_delay, user): ], ) + result = manual_scrobble_agent_follow_up("tell me more", original.id, user.id) + + assert result.id != original.id + assert result.agent_session_id == original.agent_session_id + assert result.in_progress is True + assert result.log["title"] == original.log["title"] + assert result.log["forked_from_id"] == original.id + + original.refresh_from_db() + assert len(original.log["turns"]) == 1 + assert original.in_progress is False + + turns = result.log["turns"] + assert len(turns) == 2 + assert turns[0] == original.log["turns"][0] + assert turns[1]["prompt"] == "tell me more" + assert turns[1]["response"] is None + mock_delay.assert_called_once_with(result.id, turns[1]["prompt_id"]) + + +@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay") +def test_manual_scrobble_agent_follow_up_appends_turn_when_in_progress( + mock_delay, user +): + scrobble = _mk_scrobble( + user, + in_progress=True, + turns=[ + { + "prompt_id": "abc-123", + "prompt": "hello", + "response": "hi back", + } + ], + ) + result = manual_scrobble_agent_follow_up("tell me more", scrobble.id, user.id) assert result.id == scrobble.id @@ -1127,6 +1163,7 @@ def test_manual_scrobble_agent_follow_up_appends_turn(mock_delay, user): assert turns[1]["prompt"] == "tell me more" assert turns[1]["response"] is None assert scrobble.in_progress is True + assert "forked_from_id" not in scrobble.log mock_delay.assert_called_once_with(scrobble.id, turns[1]["prompt_id"]) @@ -1137,8 +1174,8 @@ def test_manual_scrobble_agent_follow_up_unknown_scrobble(mock_delay, user): @patch("scrobbles.tasks.scrobble_agent_session_prompt.delay") -def test_agent_session_followup_post_appends_turn(mock_delay, client, user): - scrobble = _mk_scrobble( +def test_agent_session_followup_post_forks_completed_session(mock_delay, client, user): + original = _mk_scrobble( user, in_progress=False, turns=[ @@ -1150,12 +1187,48 @@ def test_agent_session_followup_post_appends_turn(mock_delay, client, user): ], ) client.force_login(user) + response = client.post( + reverse("scrobbles:agent-session-followup", args=[original.id]), + {"prompt": "tell me more"}, + ) + + assert response.status_code == 200 + assert response["HX-Redirect"] == reverse( + "scrobbles:detail", args=[response.context["object"].id] + ) + forked = response.context["object"] + assert forked.id != original.id + assert b"tell me more" in response.content + assert b"Thinking" in response.content + assert b"every 5s" in response.content + original.refresh_from_db() + assert len(original.log["turns"]) == 1 + assert original.in_progress is False + + +@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay") +def test_agent_session_followup_post_appends_turn_when_in_progress( + mock_delay, client, user +): + scrobble = _mk_scrobble( + user, + in_progress=True, + turns=[ + { + "prompt_id": "abc-123", + "prompt": "hello", + "response": "hi back", + } + ], + ) + client.force_login(user) response = client.post( reverse("scrobbles:agent-session-followup", args=[scrobble.id]), {"prompt": "tell me more"}, ) assert response.status_code == 200 + assert "HX-Redirect" not in response assert b"tell me more" in response.content assert b"Thinking" in response.content assert b"every 5s" in response.content diff --git a/vrobbler/apps/agents/models.py b/vrobbler/apps/agents/models.py index f7ac566..f2a8049 100644 --- a/vrobbler/apps/agents/models.py +++ b/vrobbler/apps/agents/models.py @@ -50,6 +50,7 @@ class AgentSessionLogData(BaseLogData): provider: Optional[str] = None model: Optional[str] = None turns: Optional[list] = None + forked_from_id: Optional[int] = None _excluded_fields = {"turns"} diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index ad9ac64..215f52b 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -1,6 +1,7 @@ import logging import re import textwrap +from copy import deepcopy from datetime import datetime, timedelta from typing import Any, Optional from uuid import uuid4 @@ -1757,12 +1758,45 @@ def manual_scrobble_agent_session( return scrobble +def _fork_agent_session_scrobble(scrobble, prompt: str, provider: str, model: str): + """Copy a completed session into a new scrobble for a follow-up turn. + + The new scrobble keeps the same agent model, title and prior turns so the + follow-up retains conversation context, while the original completed + scrobble is left untouched. + """ + new_scrobble = Scrobble.create_or_update( + scrobble.agent_session, + scrobble.user_id, + { + "user_id": scrobble.user_id, + "timestamp": timezone.now(), + "playback_position_seconds": 0, + "source": scrobble.source or "Vrobbler", + }, + skip_in_progress_check=True, + ) + log = deepcopy(scrobble.log if isinstance(scrobble.log, dict) else {}) + log["forked_from_id"] = scrobble.id + new_scrobble.log = log + new_scrobble.save(update_fields=["log"]) + prompt_id = _append_agent_turn( + new_scrobble, prompt, provider, model, set_in_progress=True + ) + return new_scrobble, prompt_id + + def manual_scrobble_agent_follow_up( prompt: str, scrobble_id: int, user_id: int, ): - """Append a follow-up turn to a specific agent session scrobble.""" + """Append a follow-up turn to a specific agent session scrobble. + + Active (in-progress) sessions are extended in place. Completed sessions are + forked into a new scrobble that carries the prior conversation, so the new + response lands in a fresh session instead of resurrecting the old one. + """ from django.conf import settings scrobble = Scrobble.objects.filter(id=scrobble_id, user_id=user_id).first() @@ -1775,9 +1809,14 @@ def manual_scrobble_agent_follow_up( settings.LLM_MODEL if provider in ("gemini", "openrouter") else provider ) - prompt_id = _append_agent_turn( - scrobble, prompt, provider, model, set_in_progress=True - ) + if scrobble.in_progress: + prompt_id = _append_agent_turn( + scrobble, prompt, provider, model, set_in_progress=True + ) + else: + scrobble, prompt_id = _fork_agent_session_scrobble( + scrobble, prompt, provider, model + ) logger.info( "[scrobblers] manual agent session follow-up received", diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py index 9cb98c2..98aaa47 100644 --- a/vrobbler/apps/scrobbles/tasks.py +++ b/vrobbler/apps/scrobbles/tasks.py @@ -39,7 +39,7 @@ SCROBBLES_WITHOUT_CHARTS = [ "geolocation", ] -AGENT_SESSION_AUTO_COMPLETE_SECONDS = 600 +AGENT_SESSION_AUTO_COMPLETE_SECONDS = settings.AGENT_SESSION_AUTO_COMPLETE_SECONDS @shared_task diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 22952b0..adffab6 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -454,12 +454,16 @@ class AgentSessionFollowUpView(LoginRequiredMixin, View): messages.error(request, "Follow-up prompt is empty.") return HttpResponseRedirect(scrobble.get_absolute_url()) + original_id = scrobble.id scrobble = manual_scrobble_agent_follow_up(prompt, scrobble.id, request.user.id) - return TemplateResponse( + response = TemplateResponse( request, self.template_name, context={"object": scrobble}, ) + if scrobble and scrobble.id != original_id: + response["HX-Redirect"] = scrobble.get_absolute_url() + return response class AgentSessionRetryView(LoginRequiredMixin, View): diff --git a/vrobbler/settings.py b/vrobbler/settings.py index dcbf8be..a22ac10 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -127,6 +127,9 @@ AGENT_PROVIDER_RETRY_BACKOFF_BASE = int( AGENT_PROVIDER_RETRY_MAX_BACKOFF = int( os.getenv("VROBBLER_AGENT_PROVIDER_RETRY_MAX_BACKOFF", "300") ) +AGENT_SESSION_AUTO_COMPLETE_SECONDS = int( + os.getenv("VROBBLER_AGENT_SESSION_AUTO_COMPLETE_SECONDS", "1200") +) AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "") AMAZON_PAAPI_SECRET_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_SECRET_KEY", "") diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.html index 7a982ea..3211ba9 100644 --- a/vrobbler/templates/scrobbles/scrobble_detail.html +++ b/vrobbler/templates/scrobbles/scrobble_detail.html @@ -88,6 +88,11 @@

{{ object.logdata.title }}

{% endif %} +{% if object.media_type == "AgentSession" and object.logdata.forked_from_id %} +

+ ← Forked from a previous session +

+{% endif %}

{{ object.local_timestamp }}

{% if user.is_authenticated and object.user == user %}