Skip to content

[feat] Durable cross-device agent session list (archive, ended, titles) - #5479

Merged
bekossy merged 14 commits into
feat/sessions-storage-reworkfrom
feat/sessions-continuity-fixes
Jul 28, 2026
Merged

[feat] Durable cross-device agent session list (archive, ended, titles)#5479
bekossy merged 14 commits into
feat/sessions-storage-reworkfrom
feat/sessions-continuity-fixes

Conversation

@ardaerzin

Copy link
Copy Markdown
Contributor

Context

The agent-chat session list was localStorage-only. It lived in a single browser, so it never synced across devices or tabs, and a delete or rename never left that browser. Open the app elsewhere and you saw nothing; a killed session simply vanished from history. This PR makes the list server-backed and reconciled, so it is durable and consistent everywhere.

What this adds

The list is now backed by the durable /sessions/query endpoint and folded over the localStorage cache by a reconciler:

  • Cross-device list. The reconciler adopts sessions the server knows that this browser does not (a fresh browser or another device), enriches titles and timestamps from the server, and drops sessions deleted elsewhere. Open tabs and the active tab stay per-device by design.
  • Server-synced titles. A rename persists to the durable stream header, and a session auto-names itself from its first user message, so the list is labeled everywhere before it is opened instead of showing "Untitled chat".
  • Ended sessions stay in history. include_ended keeps killed-but-resumable sessions in the list with a muted "Ended" chip, and resuming a killed session revives its durable row.
  • Archive is separate from kill. A new archived_at column (distinct from deleted_at) gives a hide-but-recoverable state, with archive/unarchive actions in the rail and history popover, an "Archived" section, and include_archived for the archived view. A live turn on an archived session auto-unarchives it.
  • Server-side delete. A delete fans out on the server so the session disappears on every device, not just the current browser.

Before: the session list was one browser's localStorage; delete, rename, and kill stayed local; there was no archive.

After: the list is durable server streams reconciled over localStorage; delete, rename, archive, and ended state sync across devices.

Backend

  • session_streams gains archived_at (migration oss018) plus an index on (project_id, archived_at) (oss019). archive/unarchive use archived_at so kill's deleted_at path is untouched. include_ended and include_archived are threaded through the query path and filter independently.
  • The Fern TypeScript client is regenerated so include_ended, include_archived, and archived_at are typed rather than cast at runtime.

Tests / notes

  • API: the sessions unit suite passes (135 passed). Coverage includes archive-vs-kill, revive-on-resume, and auto-unarchive-on-activity.
  • Web: entities and playground typecheck clean; the touched oss files typecheck clean.
  • Migrations are additive and oss018 revises oss017 (the current base head).
  • The Fern client was regenerated earlier on this branch. If the backend spec has moved since, a fresh generate.sh --local before merge guarantees the client matches the backend.
  • Not in this PR: a session created before the auto-title change that is never opened in any tab still shows "Untitled chat" until some tab opens it. Stamping the header server-side from the first user turn would close that fully.

What to QA

  • Open the agent chat in a second browser or incognito on the same project and agent. Your sessions appear, with titles, ended, and archived state matching the first browser.
  • Rename a session, then focus the other tab; it shows the new name. Start a new session and send a message; it auto-names from that message rather than "Untitled chat".
  • Archive a session from the rail. It moves into the "Archived" section; unarchive restores it. Both reflect in the other tab.
  • Delete a session. It disappears in the other tab too.
  • End or kill a session. It stays in history with a muted "Ended" chip and is still resumable.
  • Regression: a normal same-browser reload still shows your history, open tabs, and active tab.

ardaerzin added 12 commits July 24, 2026 16:34
A kill/archive soft-deletes the session_streams row, but the full unique
constraint on (project_id, session_id) keeps the tombstone in the slot. On the
next turn _start_turn read None, create hit the slot, and update matched nothing,
leaving the durable row dead while Redis went alive — so a resumed session
vanished from the list and fetch/heartbeat returned None. Detect the no-match
update and revive that same row (unarchive + re-flag), preserving its id and
header across the kill boundary.
Add querySessions (POST /sessions/query) + extend the stream schema with the
header (name/description) and lifecycle timestamps, so the durable session list
is reachable from the FE. Add projectSessionsQueryAtomFamily(appId), mirroring the
liveness query: one low-priority, focus/interval-revalidated query per agent,
scoped by references [{id: appId}] against the turns' workflow refs. Not yet wired
into the sidebar — the reconciler that folds this over the localStorage list follows.
Kill soft-deletes the stream row, so the default query (deleted_at IS NULL) drops
killed-but-resumable sessions from the list — under STOP_KILLS_SESSION that's most
of them. Add include_ended to SessionQuery/SessionStreamQuery so the durable list
keeps ended sessions (deleted_at populated for the caller to mark them). Absence
from the list then means genuinely hard-deleted, which the FE uses to prune its cache.
Fold the agent's /sessions/query result into sessionsByAppAtom: adopt sessions the
server knows and we don't (cross-device, and recovers a wiped localStorage), enrich
title/createdAt (a local user title wins), and drop a session the server dropped —
server-confirmed before, absent now = hard-deleted elsewhere — never a purely-local
optimistic one (tracked via a new serverKnown flag; also exempts it from husk pruning).
Open tabs/active stay per-device. Gated on query success so a failed fetch never drops.
renameSession only wrote the local cache, so a title never left the browser. Add
setSessionHeader (PUT /sessions/streams/header) and fire it from renameSession —
optimistic local update, best-effort server write. Combined with the reconciler
adopting the server name, a rename now round-trips across devices and survives a
localStorage wipe (creates the stream row if the rename precedes the first run).
Delete was localStorage-only, so a delete never left the browser. Add deleteSession
(DELETE /sessions/, the root hard-delete) and fire it from deleteSessionAtomFamily for
server-known sessions only (a local husk has no row; the reconciler's own drop path never
routes here, so no loop) — a delete now propagates to every device.

And surface killed/ended sessions: include_ended already lists soft-deleted rows, so carry
an 'ended' flag (deleted_at) through the reconciler and show a muted 'Ended' chip in the rail
and history popover. (Kill and archive both set deleted_at on this backend, so an archived
session also reads as ended until the backend distinguishes them.)
Archive and kill both set deleted_at, so the durable list couldn't tell a killed-but-
resumable session from a deliberately-hidden one. Add an archived_at column (migration
018) and repoint archive/unarchive to it: archive sets archived_at, unarchive clears it,
kill's deleted_at is untouched (so BE-1 revive is unaffected). The list hides archived_at
rows by default; a new include_archived surfaces them for an archived view — orthogonal to
include_ended. Updates the root-service archive tests to the new semantics.
Adds an archive action to the session rail and history popover, backed by the new
archived_at server state. The durable list query now includes archived rows so the
reconciler carries an `archived` flag rather than mistaking an archived session for a
hard-delete and pruning it; the flag hides the row from the main history/tabs and surfaces
it under a collapsible "Archived" section with restore + delete. Archive/unarchive fire the
server archiveSession/unarchiveSession for server-known sessions so the state syncs across
devices, and stay purely local otherwise.
Regenerated the TS client from the #5436 backend OpenAPI spec so `SessionQueryRequest`
carries `include_ended`/`include_archived` and `SessionStream` carries `archived_at` as
first-class typed fields, and dropped the runtime cast on `querySessions` that stood in for
them. Pulls in the rest of the #5436 sessions/mounts drift the checked-in client predated
(SessionTurn `turn_id`, a SessionTurnComplete request, the mount file-order enum). No new
tsc error signatures introduced (verified against the pre-regen baseline).
_start_turn only cleared deleted_at (the killed-tombstone revive), so starting a turn on an
archived-but-not-killed session updated its flags to alive/running while archived_at stayed
set — a live session absent from the default list. Now a live row that carries archived_at is
un-hidden on new activity (clears only when actually archived, so the hot path is untouched
for the common case).
The durable list filters archived_at per project (the default list excludes it, the archived
view selects it), so back that predicate with an index instead of leaning on the created_at
index alone. Mirrors ix_session_streams_project_id_created_at; migration 019.
A session started in another tab/device showed as "Untitled chat" in the durable list until
opened, because the list label comes only from the server header `name` (set on explicit rename)
and an adopted session has no local messages to derive a label from. Now the first user message
auto-titles the session (capped, synced to the durable header via setSessionHeader), so the list
is labeled everywhere before it's opened. The write atom no-ops once the session already has a
title, so it fires at most once and never overwrites an explicit rename.
@ardaerzin
ardaerzin marked this pull request as draft July 24, 2026 16:14
@ardaerzin
ardaerzin marked this pull request as ready for review July 24, 2026 16:15
@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Jul 24, 2026
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d0971a94-2c80-4add-988a-eed7153062bc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds a persistent archived_at session-stream state, query controls, archive lifecycle APIs, resume reconciliation, server-to-local session synchronization, durable title updates, and archived-session controls in the web history and session rail.

Changes

Session archiving

Layer / File(s) Summary
Storage and query contracts
api/oss/databases/postgres/migrations/..., api/oss/src/apis/fastapi/sessions/*, api/oss/src/core/sessions/..., api/oss/src/dbs/postgres/sessions/streams/*
Adds the nullable indexed archived_at column, DTO fields, query flags, conditional ended/archived filtering, and database mapping.
Archive lifecycle and resume reconciliation
api/oss/src/core/sessions/streams/*, api/oss/src/core/sessions/service.py, api/oss/tests/pytest/unit/sessions/*
Adds archive/unarchive DAO operations, forwards user_id, revives killed tombstones on resume, clears archived state on new activity, and updates lifecycle tests.
Web session API contracts
web/packages/agenta-entities/src/session/*
Adds validated session querying, durable header updates, remote lifecycle mutations, and archived/lifecycle fields to session schemas.
Server reconciliation and local state
web/oss/src/components/AgentChatSlice/state/*, web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx, web/oss/src/components/AgentChatSlice/AgentConversation.tsx
Reconciles server sessions into local state, tracks server-known/ended/archived flags, propagates mutations, persists titles, and auto-titles sessions from the first user message.
Archived session history UI
web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx, web/oss/src/components/AgentChatSlice/components/SessionRail.tsx
Adds archived sections, status badges, archive/unarchive actions, search filtering, and disabled reopening for archived rows.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentChatPanel
  participant useReconcileServerSessions
  participant querySessions
  participant SessionHistoryMenu
  AgentChatPanel->>useReconcileServerSessions: reconcile scope
  useReconcileServerSessions->>querySessions: fetch project sessions
  querySessions-->>useReconcileServerSessions: validated session summaries
  useReconcileServerSessions->>SessionHistoryMenu: update local session state
  SessionHistoryMenu->>querySessions: archive or unarchive session
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.43% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: durable cross-device session lists with archive, ended, and title support.
Description check ✅ Passed The description directly matches the changeset and explains the new session syncing, archive, ended, and title behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sessions-continuity-fixes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dosubot dosubot Bot added Backend Feature Request New feature or request Frontend labels Jul 24, 2026
@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ardaerzin ardaerzin added Frontend Backend size:XL This PR changes 500-999 lines, ignoring generated files. Feature Request New feature or request labels Jul 24, 2026
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jul 27, 2026 7:29pm

Request Review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/oss/src/core/sessions/streams/service.py (1)

497-511: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Serialize archive and turn-start transitions.

A concurrent archive can commit after the flag update but before the stale updated.archived_at check, leaving a running session hidden from the default list. A concurrent hard delete can likewise leave the revive retry empty while this method still returns a turn ID. Make archive/start/revive one per-session atomic transition (or use a shared lock), and fail or retry unless the final row update succeeds.

Also applies to: 580-612

🧹 Nitpick comments (1)
web/oss/src/components/AgentChatSlice/state/sessions.ts (1)

1-13: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Remove or wire routerAppIdAtom into the scoped session mutations.

routerAppIdAtom is unused after the import, while setSessionHeader/archive/unarchive/delete calls here, plus killSession callers, only pass sessionId/projectId. If these operations are meant to be app-scoped, use the current scope atom in these remote calls instead of leaving it unused here.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 758abb71-75b2-4b7f-8a81-46e68c99c81f

📥 Commits

Reviewing files that changed from the base of the PR and between df1530b and b709c9d.

⛔ Files ignored due to path filters (15)
  • web/packages/agenta-api-client/src/generated/api/resources/index.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/GetMountFilesRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/index.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/types/GetMountFilesRequestOrder.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/mounts/types/index.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionQueryRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnAppendRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnCompleteRequest.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/MountFile.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/SessionTurn.ts is excluded by !**/generated/**
  • web/packages/agenta-api-client/src/generated/api/types/index.ts is excluded by !**/generated/**
📒 Files selected for processing (23)
  • api/oss/databases/postgres/migrations/core_oss/versions/oss000000018_add_session_streams_archived_at.py
  • api/oss/databases/postgres/migrations/core_oss/versions/oss000000019_index_session_streams_archived_at.py
  • api/oss/src/apis/fastapi/sessions/models.py
  • api/oss/src/apis/fastapi/sessions/router.py
  • api/oss/src/core/sessions/dtos.py
  • api/oss/src/core/sessions/service.py
  • api/oss/src/core/sessions/streams/dtos.py
  • api/oss/src/core/sessions/streams/interfaces.py
  • api/oss/src/core/sessions/streams/service.py
  • api/oss/src/dbs/postgres/sessions/streams/dao.py
  • api/oss/src/dbs/postgres/sessions/streams/dbes.py
  • api/oss/src/dbs/postgres/sessions/streams/mappings.py
  • api/oss/tests/pytest/unit/sessions/test_resume_after_kill_revives_tombstone.py
  • api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py
  • web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
  • web/oss/src/components/AgentChatSlice/AgentConversation.tsx
  • web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx
  • web/oss/src/components/AgentChatSlice/components/SessionRail.tsx
  • web/oss/src/components/AgentChatSlice/state/projectSessions.ts
  • web/oss/src/components/AgentChatSlice/state/sessions.ts
  • web/packages/agenta-entities/src/session/api/api.ts
  • web/packages/agenta-entities/src/session/core/schema.ts
  • web/packages/agenta-entities/src/session/index.ts

Comment on lines +1 to +9
"""A resume after a STOP_KILLS_SESSION kill (or archive) must re-nest the durable row.

Kill/archive soft-deletes the `session_streams` row (`deleted_at`). The unique constraint on
`(project_id, session_id)` is full, so the tombstone keeps occupying the slot: on the next turn
`_start_turn` reads `None` (get filters `deleted_at IS NULL`), `create` hits the unique slot ->
`SessionStreamAlreadyExists`, and the follow-up `update` (also `deleted_at IS NULL`) matches
nothing -> no-op. Without a revive, Redis goes alive but the durable row stays a dead tombstone,
so the session vanishes from the list and `fetch`/`heartbeat` return `None`. `_start_turn` must
detect the no-match update and revive that same row (clear `deleted_at`) so resume re-nests it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the archive lifecycle description.

The docstring still says archive soft-deletes the row via deleted_at, but this change makes archive preserve deleted_at and set archived_at; only kill creates the tombstone.

Proposed wording update
-"""A resume after a STOP_KILLS_SESSION kill (or archive) must re-nest the durable row.
+"""A resume after a STOP_KILLS_SESSION kill must re-nest the durable row.

-Kill/archive soft-deletes the `session_streams` row (`deleted_at`).
+Kill soft-deletes the `session_streams` row (`deleted_at`); archive keeps it live
+and uses `archived_at` to hide it from the default list.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""A resume after a STOP_KILLS_SESSION kill (or archive) must re-nest the durable row.
Kill/archive soft-deletes the `session_streams` row (`deleted_at`). The unique constraint on
`(project_id, session_id)` is full, so the tombstone keeps occupying the slot: on the next turn
`_start_turn` reads `None` (get filters `deleted_at IS NULL`), `create` hits the unique slot ->
`SessionStreamAlreadyExists`, and the follow-up `update` (also `deleted_at IS NULL`) matches
nothing -> no-op. Without a revive, Redis goes alive but the durable row stays a dead tombstone,
so the session vanishes from the list and `fetch`/`heartbeat` return `None`. `_start_turn` must
detect the no-match update and revive that same row (clear `deleted_at`) so resume re-nests it.
"""A resume after a STOP_KILLS_SESSION kill must re-nest the durable row.
Kill soft-deletes the `session_streams` row (`deleted_at`); archive keeps it live
and uses `archived_at` to hide it from the default list. The unique constraint on
`(project_id, session_id)` is full, so the tombstone keeps occupying the slot: on the next turn
`_start_turn` reads `None` (get filters `deleted_at IS NULL`), `create` hits the unique slot ->
`SessionStreamAlreadyExists`, and the follow-up `update` (also `deleted_at IS NULL`) matches
nothing -> no-op. Without a revive, Redis goes alive but the durable row stays a dead tombstone,
so the session vanishes from the list and `fetch`/`heartbeat` return `None`. `_start_turn` must
detect the no-match update and revive that same row (clear `deleted_at`) so resume re-nests it.
"""

Comment on lines 87 to 94
async def archive(self, *, project_id, user_id, session_id):
self.archive_calls.append(
{"project_id": project_id, "user_id": user_id, "session_id": session_id}
)
if self.row is not None:
self.row = self.row.model_copy(
update={"deleted_at": "2026-01-01T00:00:00Z"}
update={"archived_at": "2026-01-01T00:00:00Z"}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
git ls-files | rg 'api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py|api/oss/src/.*SessionStream|src|sessions' | head -200

echo
echo "== inspect target test =="
sed -n '1,160p' api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py | cat -n

echo
echo "== find SessionStream/session model definitions =="
rg -n "class SessionStream|archived_at|SessionStream" api/oss/src api/oss/tests -g '*.py' | head -200

Repository: Agenta-AI/agenta

Length of output: 8994


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deterministic model_copy behavior probe =="
python3 - <<'PY'
import sys
try:
    from datetime import datetime, timezone
    from pydantic import BaseModel
except Exception as e:
    print(f"import_error: {type(e).__name__}: {e}")
    sys.exit(0)

class Model(BaseModel):
    archived_at: datetime

for value in [None, "2026-01-01T00:00:00Z", datetime(2026, 1, 1, tzinfo=timezone.utc)]:
    m = Model(archived_at=None, row=None)
    print("- value", repr(value), "type", type(value).__name__)
    try:
        m2 = m.model_copy(update={"archived_at": value, "row": m.row})
        print("  copied archived_at:", repr(m2.archived_at), "type:", type(m2.archived_at).__name__)
    except Exception as e:
        print("  copy_error:", type(e).__name__ + ":", str(e))

# Compare to construction validation.
for value in [None, "2026-01-01T00:00:00Z", datetime(2026, 1, 1, tzinfo=timezone.utc)]:
    try:
        m = Model(archived_at=value, row=None)
        print("  constructed archived_at:", repr(m.archived_at), "type:", type(m.archived_at).__name__)
    except Exception as e:
        print("  construct_error:", type(e).__name__ + ":", str(e))
PY

Repository: Agenta-AI/agenta

Length of output: 810


🏁 Script executed:

#!/bin/bash
set -u

echo "== python/pydantic availability =="
python3 - <<'PY'
from datetime import datetime, timezone
try:
    import pydantic
    print("pydantic", pydantic.__version__)
    from pydantic import BaseModel
    print("ok pydantic imported")
except Exception as e:
    print("import_error", type(e).__name__ + ":", str(e))
PY
python3 - <<'PY'
from datetime import datetime, timezone
import pydantic
print("pydantic", pydantic.__version__)
from pydantic import BaseModel
class Model(BaseModel):
    archived_at: datetime | None = None
    row: str | None = None
for value in [None, "2026-01-01T00:00:00Z", datetime(2026, 1, 1, tzinfo=timezone.utc)]:
    m = Model()
    try:
        m = m.model_copy(update={"archived_at": value})
        print("input", repr(value), "output", repr(m.archived_at), "type", type(m.archived_at).__name__)
    except Exception as e:
        print("input", repr(value), "copy_error", type(e).__name__ + ":", str(e))
PY

echo
echo "== focused test file =="
if [ -f api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py ]; then
  sed -n '1,180p' api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py | cat -n
else
  echo "missing target file"
fi

echo
echo "== focused SessionStream definitions/usages =="
rg -n "class SessionStream|archived_at|SessionStream" api/oss/src api/oss/tests || true

Repository: Agenta-AI/agenta

Length of output: 46919


Keep the archive mock’s timestamp type-faithful.

SessionStream.archived_at is modeled as Optional[datetime], and model_copy(update=...) in the supported Pydantic version preserves unvalidated strings. Use a timezone-aware datetime here so the fake row matches production’s typed value.

Proposed fix
-                update={"archived_at": "2026-01-01T00:00:00Z"}
+                update={"archived_at": datetime(2026, 1, 1, tzinfo=timezone.utc)}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def archive(self, *, project_id, user_id, session_id):
self.archive_calls.append(
{"project_id": project_id, "user_id": user_id, "session_id": session_id}
)
if self.row is not None:
self.row = self.row.model_copy(
update={"deleted_at": "2026-01-01T00:00:00Z"}
update={"archived_at": "2026-01-01T00:00:00Z"}
)
async def archive(self, *, project_id, user_id, session_id):
self.archive_calls.append(
{"project_id": project_id, "user_id": user_id, "session_id": session_id}
)
if self.row is not None:
self.row = self.row.model_copy(
update={"archived_at": datetime(2026, 1, 1, tzinfo=timezone.utc)}
)

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ardaerzin lgtm

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Jul 24, 2026
The unit fakes stamped datetime lifecycle fields with ISO strings via
model_copy(update=...), which does not re-validate, so archived_at/deleted_at
stayed str where the DTOs declare Optional[datetime]. Use real datetimes
throughout. Also correct two stale docstrings that described archive as
soft-deleting via deleted_at; archive round-trips archived_at and never
touches the kill tombstone path.
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Status Destroyed (PR closed)

Updated at 2026-07-28T07:12:47.529Z

@bekossy
bekossy merged commit 80d71a1 into feat/sessions-storage-rework Jul 28, 2026
39 of 40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Feature Request New feature or request Frontend lgtm This PR has been approved by a maintainer size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants