Compare commits

..

2 Commits

Author SHA1 Message Date
Carlos Escalante
0f34a64e51 Assistant: reliable "Nueva conversación" reset
All checks were successful
Deploy to VPS / test (push) Successful in 1m33s
Deploy to VPS / deploy (push) Successful in 29s
Clearing in place (setMessages + remount) left the conversation
rendered — CopilotKit keeps chat state beyond agent.messages — so the
button now reloads the page after deleting the thread: a fresh boot
hydrates from the empty store and shows the welcome screen with
suggestions, which previously only appeared after a manual refresh.

Supporting changes:
- CopilotChat gets an explicit agentId="wealthysmart" (without it, it
  resolves DEFAULT_AGENT_ID and can bind a different client agent
  instance than useAgent's).
- The BFF answers CopilotChat's POST …/connect with an
  immediately-completed stream: the runtime's in-memory runner replays
  this PROCESS's event history, which resurrects cleared conversations
  and double-feeds hydration. The empty-messages hydration run against
  the DB store is the single restore path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:33:41 -06:00
Carlos Escalante
7b7c741ba2 Agent: resolve "today" in the browser's timezone, not hardcoded CR
The CR-hardcoded today broke two scenarios: the owner traveling, and
any future non-CR user. Now the provider sends the browser's IANA
timezone (X-Client-Timezone) with every CopilotKit request — the
runtime forwards x-* headers to the agent on its own — and the backend
binds it to a per-request ContextVar next to the DB session.
get_current_date and the future-cuota bounds use it; the tool also
reports the timezone so the model can echo it. Unknown or absent zones
(n8n posts, tests) fall back to Costa Rica; comma-joined duplicate
header values are tolerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:33:27 -06:00
8 changed files with 122 additions and 21 deletions

View File

@@ -21,9 +21,10 @@ Context you can rely on:
qualifiers, assume they mean the calendar month unless they mention qualifiers, assume they mean the calendar month unless they mention
"cycle", "corte", or their credit card. "cycle", "corte", or their credit card.
- You do NOT have a reliable clock, and the server runs in UTC while the - You do NOT have a reliable clock, and the server runs in UTC while the
user lives in Costa Rica (UTC-6). Whenever the question involves a user may be anywhere (their browser reports their timezone per request).
relative date — "hoy", "ayer", "este mes", "este ciclo", "el año Whenever the question involves a relative date — "hoy", "ayer", "este
pasado" — call get_current_date FIRST and derive ranges from its answer. mes", "este ciclo", "el año pasado" — call get_current_date FIRST and
derive ranges from its answer.
- Amounts are stored as raw numbers in their native currency (see `currency` - Amounts are stored as raw numbers in their native currency (see `currency`
field on transactions/accounts). Tools that return `total_crc` are already field on transactions/accounts). Tools that return `total_crc` are already
converted; tools that return per-transaction amounts are NOT. converted; tools that return per-transaction amounts are NOT.

View File

@@ -41,7 +41,7 @@ from app.services.budget_projection import (
get_cycle_range, get_cycle_range,
) )
from app.services import exchange_rate as fx from app.services import exchange_rate as fx
from app.timeutil import today_cr from app.timeutil import client_tz, today_client
from app.services.exchange_rate import ( from app.services.exchange_rate import (
get_converted_amount_expr, get_converted_amount_expr,
get_current_rate, get_current_rate,
@@ -145,8 +145,8 @@ def get_recent_transactions(
), ),
# Tasa Cero generates future-dated cuotas; "recent" means already # Tasa Cero generates future-dated cuotas; "recent" means already
# billed (same rule as /transactions/recent). Bound is end of the # billed (same rule as /transactions/recent). Bound is end of the
# user's today, Costa Rica time. # user's today in their request timezone (CR fallback).
Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min), Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
) )
if source: if source:
q = q.where(Transaction.source == TransactionSource(source)) q = q.where(Transaction.source == TransactionSource(source))
@@ -519,7 +519,7 @@ def get_daily_spending(
Transaction.date >= datetime.fromisoformat(start_date), Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date), Transaction.date < datetime.fromisoformat(end_date),
# Future-dated Tasa Cero cuotas are not money already spent. # Future-dated Tasa Cero cuotas are not money already spent.
Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min), Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
) )
.group_by(func.date(Transaction.date)) .group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date)) .order_by(func.date(Transaction.date))
@@ -531,12 +531,12 @@ def get_daily_spending(
def get_current_date() -> dict: def get_current_date() -> dict:
"""Today's date in the user's timezone (Costa Rica, UTC-6) and the """Today's date in the user's own timezone (sent by their browser;
active credit-card billing cycle. ALWAYS call this before resolving any Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
relative date reference — 'hoy', 'ayer', 'este mes', 'este ciclo', call this before resolving any relative date reference — 'hoy', 'ayer',
'el ciclo pasado' — the server clock and your own assumptions about 'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
today are unreliable.""" own assumptions about today are unreliable."""
today = today_cr() today = today_client()
# get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before # get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before
# the 18th we are still in the cycle that started last month. # the 18th we are still in the cycle that started last month.
if today.day >= 18: if today.day >= 18:
@@ -549,6 +549,7 @@ def get_current_date() -> dict:
return { return {
"date": today.isoformat(), "date": today.isoformat(),
"weekday": today.strftime("%A"), "weekday": today.strftime("%A"),
"timezone": str(client_tz()),
"cycle_year": cycle_year, "cycle_year": cycle_year,
"cycle_month": cycle_month, "cycle_month": cycle_month,
"cycle_range": [start.date().isoformat(), end.date().isoformat()], "cycle_range": [start.date().isoformat(), end.date().isoformat()],

View File

@@ -23,6 +23,7 @@ from app.auth import (
from app.config import settings from app.config import settings
from app.db import get_session, run_alembic_upgrade from app.db import get_session, run_alembic_upgrade
from app.seed import seed_db from app.seed import seed_db
from app.timeutil import reset_client_timezone, set_client_timezone
from app.services.exchange_rate import refresh_rates_periodically from app.services.exchange_rate import refresh_rates_periodically
@@ -136,9 +137,13 @@ async def agent_auth_and_session(request: Request, call_next):
session_gen = get_session() session_gen = get_session()
session = next(session_gen) session = next(session_gen)
token_var = set_session(session) token_var = set_session(session)
# Browser's IANA timezone, forwarded by the BFF — lets tools resolve
# "today" wherever the user is (falls back to Costa Rica).
tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
try: try:
return await call_next(request) return await call_next(request)
finally: finally:
reset_client_timezone(tz_token)
reset_session(token_var) reset_session(token_var)
try: try:
next(session_gen) next(session_gen)

View File

@@ -1,16 +1,54 @@
from datetime import date, datetime, timedelta, timezone import contextvars
from datetime import date, datetime, timedelta, timezone, tzinfo
from zoneinfo import ZoneInfo
# Costa Rica has no DST; a fixed offset is exact year-round. # Costa Rica has no DST; a fixed offset is exact year-round. This is the
# FALLBACK when the request doesn't declare a timezone (n8n posts, tests).
CR_TZ = timezone(timedelta(hours=-6)) CR_TZ = timezone(timedelta(hours=-6))
# Per-request client timezone, set by the agent middleware from the
# X-Client-Timezone header (IANA name sent by the browser). Falls back to
# Costa Rica so headless callers keep the owner's local semantics.
_client_tz: contextvars.ContextVar[tzinfo] = contextvars.ContextVar(
"client_tz", default=CR_TZ
)
def today_cr() -> date:
"""Current date in Costa Rica (UTC-6). def set_client_timezone(name: str | None) -> contextvars.Token | None:
"""Bind the request's IANA timezone; ignores unknown/absent names.
Proxies that see the header twice merge the values into a comma list
("Zone, Zone") — take the first entry.
"""
if not name:
return None
try:
return _client_tz.set(ZoneInfo(name.split(",")[0].strip()))
except (KeyError, ValueError):
return None
def reset_client_timezone(token: contextvars.Token | None) -> None:
if token is not None:
_client_tz.reset(token)
def client_tz() -> tzinfo:
return _client_tz.get()
def today_client() -> date:
"""Current date where the USER is (request timezone, CR fallback).
The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow" The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow"
in UTC. Anything user-facing that means "today" (agent answers, date in UTC. Anything user-facing that means "today" (agent answers, date
defaults) must come from here, never date.today(). defaults) must come from here, never date.today().
""" """
return datetime.now(client_tz()).date()
def today_cr() -> date:
"""Current date in Costa Rica (UTC-6) — timezone-independent fallback."""
return datetime.now(CR_TZ).date() return datetime.now(CR_TZ).date()

View File

@@ -214,3 +214,26 @@ def test_daily_spending_day_scoped(bound_session):
assert out[0]["date"] == today.date().isoformat() assert out[0]["date"] == today.date().isoformat()
assert out[0]["total_crc"] == 150.0 assert out[0]["total_crc"] == 150.0
assert out[0]["count"] == 2 assert out[0]["count"] == 2
def test_get_current_date_respects_client_timezone():
from datetime import datetime, timezone as tz
from app.timeutil import (
client_tz,
reset_client_timezone,
set_client_timezone,
)
token = set_client_timezone("Asia/Tokyo")
try:
out = tools.get_current_date()
assert out["timezone"] == "Asia/Tokyo"
assert out["date"] == datetime.now(client_tz()).date().isoformat()
finally:
reset_client_timezone(token)
# Unknown names are ignored — CR fallback stays active.
assert set_client_timezone("Not/AZone") is None
assert set_client_timezone(None) is None
assert tools.get_current_date()["timezone"] == "UTC-06:00"

View File

@@ -384,12 +384,28 @@ app.use("*", async (c, next) => {
}); });
app.all("/api/copilotkit/*", async (c) => { app.all("/api/copilotkit/*", async (c) => {
// CopilotChat fires POST …/agent/<id>/connect for explicit threadIds and
// the runtime's in-memory runner replays THIS PROCESS's event history —
// stale relative to the DB store (it resurrects cleared conversations and
// double-feeds page-load hydration). The empty-messages hydration run
// against MAF is our single restore path, so connect gets an
// immediately-completed stream instead.
if (new URL(c.req.url).pathname.endsWith("/connect")) {
return new Response("", {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
const cookieHeader = c.req.header("cookie") ?? ""; const cookieHeader = c.req.header("cookie") ?? "";
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/); const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
const token = match?.[1]; const token = match?.[1];
const agentHeaders: Record<string, string> = token const agentHeaders: Record<string, string> = token
? { Authorization: `Bearer ${token}` } ? { Authorization: `Bearer ${token}` }
: {}; : {};
// Note: the browser's X-Client-Timezone header (set in App.tsx) reaches
// the backend without help — CopilotKit's runtime forwards authorization
// and all x-* headers to the agent (extractForwardableHeaders).
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders }); const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these // CK_MIDDLEWARES=off runs the stack bare — used to audit which of these

View File

@@ -77,7 +77,17 @@ export default function App() {
<PrivacyProvider> <PrivacyProvider>
<AuthProvider> <AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart"> <CopilotKit
runtimeUrl="/api/copilotkit"
agent="wealthysmart"
headers={{
// Lets the agent resolve "hoy"/"este mes" in the user's
// local time wherever they are; backend falls back to
// Costa Rica when absent.
"X-Client-Timezone":
Intl.DateTimeFormat().resolvedOptions().timeZone,
}}
>
<AppRoutes /> <AppRoutes />
<Toaster richColors position="top-right" closeButton /> <Toaster richColors position="top-right" closeButton />
</CopilotKit> </CopilotKit>

View File

@@ -82,11 +82,13 @@ export default function Asistente() {
method: "DELETE", method: "DELETE",
credentials: "include", credentials: "include",
}); });
agent?.setMessages([]); // Full reload is the reliable reset: CopilotKit keeps chat state in
setConfirmClear(false); // more places than agent.messages (clearing it in place left the
// conversation rendered), while a fresh boot hydrates from the
// now-empty store and shows the welcome screen + suggestions.
window.location.reload();
} catch (err) { } catch (err) {
console.error("[asistente] clear thread failed:", err); console.error("[asistente] clear thread failed:", err);
} finally {
setClearing(false); setClearing(false);
} }
}; };
@@ -165,6 +167,11 @@ export default function Asistente() {
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card"> <div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat <CopilotChat
className="h-full" className="h-full"
// Without an explicit agentId, CopilotChat resolves DEFAULT_AGENT_ID
// ("default") — a DIFFERENT client agent instance than
// useAgent({agentId:"wealthysmart"}); clearing one leaves the other
// rendering its own copy of the conversation.
agentId="wealthysmart"
threadId={CHAT_THREAD_ID} threadId={CHAT_THREAD_ID}
labels={{ labels={{
modalHeaderTitle: "WealthySmart", modalHeaderTitle: "WealthySmart",