Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/apis/app_api/admin/costs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ class UserSessionsResponse(BaseModel):
# Sessions excluded because their cost is unrecorded (they are still listed,
# with costKnown=False, when sort != "cost"; under cost-sort they trail).
unknown_cost_count: int = Field(0, alias="unknownCostCount")
# Soft-deleted conversations in the list (status="deleted"). Listed, not
# hidden: a delete removes the row from the user's sidebar, not its cost
# rows or its share of `userPeriodCost`, so an audit that dropped them
# could not account for the period total.
deleted_session_count: int = Field(0, alias="deletedSessionCount")
deleted_session_cost: float = Field(0.0, alias="deletedSessionCost")


class AttachmentProfile(BaseModel):
Expand Down
16 changes: 14 additions & 2 deletions backend/src/apis/app_api/admin/costs/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,15 +831,23 @@ async def get_user_sessions(
period = None if all_time else (period or self._get_current_period())
active_since = self._get_period_date_range(period)[0] if period else None

# Deleted conversations stay in the list: their cost rows and their
# share of the period total outlive the delete, so hiding them left
# a user's spend unaccounted for (one $3.77 row against $20.32).
rows = await self.storage.get_user_session_diagnostics(
user_id=user_id,
active_since=active_since,
include_deleted=True,
)
user_period_cost = await self._user_period_cost(user_id, period)
threshold = compaction_token_threshold()

summaries: List[UserSessionSummary] = []
for row in rows:
if row.get("deleted") or row.get("status") == "deleted":
# Legacy tombstones carry `deleted` without the status flip;
# normalise so the page has one signal to render.
row["status"] = "deleted"
share = self._share(_as_float(row.get("totalCost")), user_period_cost)
findings = run_diagnoses(self._row_facts(row, threshold, share))
summaries.append(self._session_summary(row, findings, share))
Expand All @@ -860,9 +868,11 @@ def recent_key(s: UserSessionSummary) -> str:
)

unknown = sum(1 for s in summaries if not s.cost_known)
deleted = [s for s in summaries if s.status == "deleted"]
deleted_cost = sum(s.total_cost for s in deleted if s.total_cost is not None)
logger.info(
f"User sessions: {len(summaries)} rows ({unknown} unknown-cost), "
f"returning {min(limit, len(summaries))}"
f"User sessions: {len(summaries)} rows ({unknown} unknown-cost, "
f"{len(deleted)} deleted), returning {min(limit, len(summaries))}"
)
return UserSessionsResponse(
user_id=user_id,
Expand All @@ -871,6 +881,8 @@ def recent_key(s: UserSessionSummary) -> str:
sessions=summaries[:limit],
total=len(summaries),
unknown_cost_count=unknown,
deleted_session_count=len(deleted),
deleted_session_cost=round(deleted_cost, 6),
)

async def _attachment_profile(self, session_id: str) -> AttachmentProfile:
Expand Down
18 changes: 15 additions & 3 deletions backend/src/apis/shared/storage/dynamodb_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,11 +825,20 @@ async def get_user_session_diagnostics(
self,
user_id: str,
active_since: Optional[str] = None,
include_deleted: bool = False,
) -> List[Dict[str, Any]]:
"""One user's session rows, content-free, with everything a diagnostic
list needs: cost/cache rollups, context, model, enabled tool ids, agent
binding, compaction coordinates and the behavioral counters.

``include_deleted=True`` keeps soft-deleted rows (``deleted`` /
``status="deleted"``). A delete is a tombstone, not a refund: the
session's ``C#`` rows and its share of the user's period total survive
it, so an audit that hides these rows cannot account for the user's
spend — one prod user showed a single $3.77 conversation against
$20.32 of period cost. The default stays exclusive for callers that
list what the user can still open.

Same bounded base-table query as :meth:`get_user_session_costs`; the
difference is the projection (``SESSION_ROW_PROJECTION``) and the
post-processing: ``compaction.summary`` is measured into
Expand All @@ -848,6 +857,7 @@ async def get_user_session_diagnostics(
active_since=active_since,
projection=projection,
names=names,
include_deleted=include_deleted,
)
return [self._content_free_session_row(item) for item in items]

Expand All @@ -857,13 +867,15 @@ async def _query_user_session_rows(
active_since: Optional[str],
projection: str,
names: Optional[Dict[str, str]],
include_deleted: bool = False,
) -> List[Dict[str, Any]]:
"""Shared body of the two per-user session readers.

``PK = USER#<id>``, ``SK begins_with S#`` — matches both the static
(``S#<id>``) and legacy (``S#ACTIVE#…``) schemes and no other row
family. Paginates, converts Decimals, drops soft-deleted rows, and
applies ``active_since`` client-side (``lastMessageAt`` is not a key).
family. Paginates, converts Decimals, drops soft-deleted rows unless
``include_deleted``, and applies ``active_since`` client-side
(``lastMessageAt`` is not a key).
"""
from boto3.dynamodb.conditions import Key

Expand All @@ -890,7 +902,7 @@ async def _query_user_session_rows(
results = []
for item in items:
item_float = self._convert_decimal_to_float(item)
if item_float.get("deleted"):
if item_float.get("deleted") and not include_deleted:
continue
if active_since:
last_message_at = item_float.get("lastMessageAt") or ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async def test_all_time_drops_the_period_and_the_share():
assert resp.period is None and resp.user_period_cost is None
assert resp.sessions[0].share_of_user_period is None
# No period → no active_since filter reaches storage.
service.storage.get_user_session_diagnostics.assert_awaited_once_with(user_id="u1", active_since=None)
service.storage.get_user_session_diagnostics.assert_awaited_once_with(user_id="u1", active_since=None, include_deleted=True)
service.storage.get_user_cost_summary.assert_not_awaited()


Expand All @@ -123,7 +123,7 @@ async def test_period_scoping_passes_the_month_start_to_storage():
service = _service([])
await service.get_user_sessions("u1", period="2026-09")
service.storage.get_user_session_diagnostics.assert_awaited_once_with(
user_id="u1", active_since="2026-09-01"
user_id="u1", active_since="2026-09-01", include_deleted=True
)


Expand Down Expand Up @@ -160,3 +160,43 @@ async def test_a_broken_period_cost_lookup_degrades_to_no_share():
resp = await service.get_user_sessions("u1", period="2026-09")
assert resp.user_period_cost is None
assert resp.sessions[0].share_of_user_period is None


@pytest.mark.asyncio
async def test_deleted_conversations_are_listed_flagged_and_rolled_up():
service = _service(
[
_row("live", totalCost=0.5),
_row("gone", totalCost=2.25, deleted=True, status="deleted"),
_row("legacy-tombstone", totalCost=1.0, deleted=True, status="active"),
_row("gone-unpriced", totalCost=None, deleted=True, status="deleted"),
],
period_cost=4.0,
)

response = await service.get_user_sessions(user_id="u1", period="2026-09")

# The storage reader is asked for tombstones explicitly — the default
# reader hides them, which is what left a $20 month showing one $3 row.
service.storage.get_user_session_diagnostics.assert_awaited_once()
assert service.storage.get_user_session_diagnostics.await_args.kwargs["include_deleted"] is True

by_id = {s.session_id: s for s in response.sessions}
assert by_id["live"].status == "active"
assert by_id["gone"].status == "deleted"
# A legacy tombstone carries `deleted` without the status flip; the page
# gets one normalised signal.
assert by_id["legacy-tombstone"].status == "deleted"
assert response.total == 4
assert response.deleted_session_count == 3
assert response.deleted_session_cost == pytest.approx(3.25)
# Deleted rows still take their share of the period total.
assert by_id["gone"].share_of_user_period == pytest.approx(56.25)


@pytest.mark.asyncio
async def test_no_deleted_conversations_reports_zero():
service = _service([_row("live")], period_cost=1.0)
response = await service.get_user_sessions(user_id="u1", period="2026-09")
assert response.deleted_session_count == 0
assert response.deleted_session_cost == 0.0
28 changes: 24 additions & 4 deletions backend/tests/costs/test_content_free_projections.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,32 @@ async def test_top_sessions_reader_keeps_title_by_decision(storage):
assert "compaction" not in rows[0] # and it never widened into the diagnostic fields


@pytest.mark.asyncio
async def test_deleted_sessions_are_excluded_from_the_diagnostic_list(storage):
_seed(storage)
def _seed_deleted(storage):
storage.sessions_metadata_table.put_item(Item={
"PK": f"USER#{USER_ID}", "SK": "S#gone", "GSI_PK": "SESSION#gone", "GSI_SK": "META",
"sessionId": "gone", "userId": USER_ID, "deleted": True, "totalCost": Decimal("9"),
"sessionId": "gone", "userId": USER_ID, "deleted": True, "status": "deleted",
"totalCost": Decimal("9"), "title": "DELETED TITLE",
})


@pytest.mark.asyncio
async def test_deleted_sessions_are_excluded_from_the_diagnostic_list_by_default(storage):
_seed(storage)
_seed_deleted(storage)
rows = await storage.get_user_session_diagnostics(USER_ID)
assert [r["sessionId"] for r in rows] == [SESSION_ID]


@pytest.mark.asyncio
async def test_deleted_sessions_are_listed_on_request_and_stay_content_free(storage):
# A delete is a tombstone, not a refund: the row's cost survives it, so
# the audit must be able to see it to account for the period total.
_seed(storage)
_seed_deleted(storage)
rows = await storage.get_user_session_diagnostics(USER_ID, include_deleted=True)
by_id = {r["sessionId"]: r for r in rows}
assert set(by_id) == {SESSION_ID, "gone"}
assert by_id["gone"]["deleted"] is True
assert by_id["gone"]["status"] == "deleted"
assert by_id["gone"]["totalCost"] == 9
assert "title" not in by_id["gone"]
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,24 @@ describe('UserConversationsComponent', () => {
await vi.waitFor(() => expect(fixture.componentInstance.sessionsResource.error()).toBeTruthy());
expect(fixture.componentInstance.summaryLine()).toBe('');
});

it('lists deleted conversations and says their cost still counts', async () => {
const fixture = setup(
vi.fn().mockReturnValue(
of({
...RESPONSE,
total: 2,
deletedSessionCount: 1,
deletedSessionCost: 2.25,
sessions: [RESPONSE.sessions[0], { ...RESPONSE.sessions[0], sessionId: 'gone-1234-5678', status: 'deleted', totalCost: 2.25 }],
}),
),
);
const c = fixture.componentInstance;
await vi.waitFor(() => expect(c.sessionsResource.hasValue()).toBe(true));
expect(c.summaryLine()).toBe('2 conversations · $5.12 recorded this month · 2 with unrecorded cost · 1 deleted ($2.25 still counted in the total)');
// Deleted rows stay in the list, flagged, rather than being dropped.
const deleted = (c.sessionsResource.value()?.sessions ?? []).filter((s) => s.status === 'deleted');
expect(deleted.map((s) => s.sessionId)).toEqual(['gone-1234-5678']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ import {
@if (s.agentBound) {
<span class="ml-2 rounded-sm bg-gray-100 px-1.5 font-mono text-[10px]/5 text-gray-600 dark:bg-white/10 dark:text-gray-300">agent</span>
}
@if (s.status === 'deleted') {
<span
class="ml-2 rounded-sm bg-gray-100 px-1.5 font-mono text-[10px]/5 text-gray-600 dark:bg-white/10 dark:text-gray-300"
title="Deleted by the user. Its cost rows and its share of the period total survive the delete."
>deleted</span
>
}
</td>
<td class="whitespace-nowrap px-3 py-2 tabular-nums">
{{ s.lastMessageAt ? (s.lastMessageAt | date: 'MMM d, HH:mm') : '—' }}
Expand Down Expand Up @@ -217,6 +224,13 @@ export class UserConversationsComponent {
];
if (v.userPeriodCost != null) parts.push(`${this.currency(v.userPeriodCost)} recorded this month`);
if (v.unknownCostCount > 0) parts.push(`${v.unknownCostCount} with unrecorded cost`);
if ((v.deletedSessionCount ?? 0) > 0) {
// A delete is a tombstone, not a refund — say so, or the period total
// will not add up to the rows on screen.
parts.push(
`${v.deletedSessionCount} deleted (${this.currency(v.deletedSessionCost ?? 0)} still counted in the total)`,
);
}
return parts.join(' · ');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ export interface UserSessionsResponse {
total: number;
/** Rows whose cost is unrecorded (listed, flagged, trailing under cost-sort). */
unknownCostCount: number;
/**
* Soft-deleted conversations in the list (`status === 'deleted'`). Listed,
* not hidden: a delete removes the row from the user's sidebar, not its cost
* rows or its share of `userPeriodCost`.
*/
deletedSessionCount?: number;
deletedSessionCost?: number;
}

export type UserSessionsSort = 'cost' | 'recent' | 'context' | 'messages';
Expand Down