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 %}