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
"cycle", "corte", or their credit card.
- 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
relative date — "hoy", "ayer", "este mes", "este ciclo", "el año
pasado" — call get_current_date FIRST and derive ranges from its answer.
user may be anywhere (their browser reports their timezone per request).
Whenever the question involves a relative date — "hoy", "ayer", "este
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`
field on transactions/accounts). Tools that return `total_crc` are already
converted; tools that return per-transaction amounts are NOT.

View File

@@ -41,7 +41,7 @@ from app.services.budget_projection import (
get_cycle_range,
)
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 (
get_converted_amount_expr,
get_current_rate,
@@ -145,8 +145,8 @@ def get_recent_transactions(
),
# Tasa Cero generates future-dated cuotas; "recent" means already
# billed (same rule as /transactions/recent). Bound is end of the
# user's today, Costa Rica time.
Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min),
# user's today in their request timezone (CR fallback).
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
)
if 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(end_date),
# 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))
.order_by(func.date(Transaction.date))
@@ -531,12 +531,12 @@ def get_daily_spending(
def get_current_date() -> dict:
"""Today's date in the user's timezone (Costa Rica, UTC-6) and the
active credit-card billing cycle. ALWAYS call this before resolving any
relative date reference — 'hoy', 'ayer', 'este mes', 'este ciclo',
'el ciclo pasado' — the server clock and your own assumptions about
today are unreliable."""
today = today_cr()
"""Today's date in the user's own timezone (sent by their browser;
Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
call this before resolving any relative date reference — 'hoy', 'ayer',
'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
own assumptions about today are unreliable."""
today = today_client()
# 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.
if today.day >= 18:
@@ -549,6 +549,7 @@ def get_current_date() -> dict:
return {
"date": today.isoformat(),
"weekday": today.strftime("%A"),
"timezone": str(client_tz()),
"cycle_year": cycle_year,
"cycle_month": cycle_month,
"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.db import get_session, run_alembic_upgrade
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
@@ -136,9 +137,13 @@ async def agent_auth_and_session(request: Request, call_next):
session_gen = get_session()
session = next(session_gen)
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:
return await call_next(request)
finally:
reset_client_timezone(tz_token)
reset_session(token_var)
try:
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))
# 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"
in UTC. Anything user-facing that means "today" (agent answers, date
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()

View File

@@ -214,3 +214,26 @@ def test_daily_spending_day_scoped(bound_session):
assert out[0]["date"] == today.date().isoformat()
assert out[0]["total_crc"] == 150.0
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) => {
// 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 match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
const token = match?.[1];
const agentHeaders: Record<string, string> = 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 });
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these

View File

@@ -77,7 +77,17 @@ export default function App() {
<PrivacyProvider>
<AuthProvider>
<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 />
<Toaster richColors position="top-right" closeButton />
</CopilotKit>

View File

@@ -82,11 +82,13 @@ export default function Asistente() {
method: "DELETE",
credentials: "include",
});
agent?.setMessages([]);
setConfirmClear(false);
// Full reload is the reliable reset: CopilotKit keeps chat state in
// 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) {
console.error("[asistente] clear thread failed:", err);
} finally {
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">
<CopilotChat
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}
labels={{
modalHeaderTitle: "WealthySmart",