Compare commits

...

17 Commits

Author SHA1 Message Date
Ryan Chen a1cdc60699 Align spending report groups and totals with gross outflows 2026-10-12 20:15:49 -04:00
ryan 2104e9f694 Merge pull request 'State spending-report outflows and inflows separately' (#63) from feat/ynab-report-totals into main 2026-10-12 19:48:17 -04:00
Ryan Chen a5ea220cdd Merge main into spending report totals and preserve artifact output 2026-10-12 19:47:52 -04:00
ryan 5d504e0314 Merge pull request 'Budget conversation context and preserve complete tool exchanges' (#69) from feat/context-management into main 2026-10-12 19:46:36 -04:00
Ryan Chen 1c9405cfdf Budget conversation context while preserving current turns and tool exchanges 2026-10-12 19:45:51 -04:00
ryan 78259855c8 Merge pull request 'Prevent active chat streams from being cancelled after 60 seconds' (#68) from fix/stream-stall-timeout into main 2026-10-12 19:38:22 -04:00
Ryan Chen 2cb131bbd2 Prevent Quart from cancelling active chat streams after 60 seconds 2026-10-12 19:38:04 -04:00
ryan 9e993f420f Merge pull request 'Treat thinking as progress in stream timeout; trim replayed history' (#67) from fix/stream-stall-timeout into main 2026-10-12 19:14:30 -04:00
Ryan Chen 4ce254193e Treat thinking as progress in stream timeout; trim replayed history
The 30s time-to-first-token guard measured from the turn's first model call
to the first answer token, so thinking, tool execution and the second call's
prefill all counted against it. Long reasoning on a long conversation was
cancelled mid-generation (llama-server: stop: cancel task at n_gen=1287).

- stream_query: the timeout now fires only when an in-flight model call goes
  silent for TTFT_TIMEOUT_SECONDS. Any chunk (thinking or empty) resets the
  clock, each model call starts a fresh one, and time spent in tools between
  calls doesn't count.
- Trim replayed history (blueprints/conversation/context.py): tool results
  and user messages from earlier turns are capped at 4000 chars (attachments
  included) and their images replaced by a placeholder. The current turn is
  untouched. The window no longer starts with orphaned ToolMessages.
- Log per-call prefill time, duration and prompt/output tokens
  (stream_usage=True) plus max_prompt_tokens in the stream_request line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:14:02 -04:00
ryan 37a8b95887 Merge pull request 'Cowork E: drop files into the chat (PDF, text, CSV, images)' (#66) from feat/cowork-file-drop into main 2026-10-12 18:47:29 -04:00
Ryan Chen 62416fcf2e Drop overlay sits above the artifact sheet below lg
Beside the panel (lg and up) it still covers only the chat column. Below
lg the panel is a fixed z-40 sheet, so the overlay covers the window at
z-[45] (under the z-50 mobile drawer) instead of being hidden by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:45:56 -04:00
Ryan Chen cc29ae1ef8 Extract attachment text off the event loop; reject text-less files
- extract_text (PyMuPDF for PDFs) now runs via asyncio.to_thread in both
  /upload and stream-query, so a large PDF doesn't stall other streams.
- /upload returns 422 when extraction yields no text: a scanned PDF
  without a text layer, or an empty text file.
- Mock server: *scan*.pdf uploads return that 422.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:43:10 -04:00
Ryan Chen 35faeb0fb7 Frontend: attach files by picker or drop, with an attachment chip
- pendingImage becomes pendingAttachment (image or PDF/TXT/MD/CSV), uploaded
  through POST /api/conversation/upload and sent as image_key or file_key.
- MessageInput shows an icon/name/size chip for files, an inline error for
  unsupported or oversized files, and a paperclip picker.
- DropOverlay covers the chat area with "Drop to attach" while files are
  dragged over the window; drags without files are ignored.
- QuestionBubble renders a saved trailing "📎 name" as a chip.
- test-fixtures/cowork/mock-server.mjs: dependency-free mock API (static
  dist/, conversations, contract-shaped SSE, uploads, artifacts) for
  Playwright runs without Docker or OIDC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:43:05 -04:00
Ryan Chen 3e50aecb96 Desktop: let HTML5 file drops reach the webview
Tauri v2 intercepts drag-and-drop by default, so the chat's drop overlay
never saw dropped files. Disable its handler on the main window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:41:56 -04:00
Ryan Chen b2f0b9f9f1 Add conversation file upload and file_key in stream-query
POST /api/conversation/upload takes an image (via process_image) or a
PDF/txt/md/csv file, checks the conversation belongs to the caller, and
stores files at conversations/<id>/files/<uuid>.<ext> with the original
name in S3 metadata. Anything else gets a 415.

stream-query accepts file_key (must sit under this conversation's
files/ prefix), extracts its text capped at 12k chars, appends it to the
agent's user turn as <attached file> and records 📎 name on the saved
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:41:56 -04:00
ryan 55c42fdfc1 Merge pull request 'Cowork D: artifact side panel with per-kind renderers' (#65) from feat/cowork-artifact-panel into main 2026-10-12 18:41:43 -04:00
Ryan Chen ca705701bd State spending-report outflows and inflows separately
The ynab_spending_report 'total' was a net figure: inflows (deposits into
budget accounts) subtracted from the spending total, which the model
repeated back as spending. The report now states total outflows and
inflows separately, drops net-negative groups from the per-group list,
and computes the monthly average from outflows.
2026-10-12 18:28:16 -04:00
26 changed files with 2155 additions and 121 deletions
+7
View File
@@ -24,7 +24,14 @@ BASE_URL=192.168.1.5:8000
# If set, uses llama-server as the primary LLM backend with OpenAI as fallback
LLAMA_SERVER_URL=http://192.168.1.213:8080/v1
LLAMA_MODEL_NAME=llama-3.1-8b-instruct
# Abandon a turn when an in-flight model call produces no output (prefill included)
# for this long. Thinking tokens count as output, so long reasoning is fine.
TTFT_TIMEOUT_SECONDS=30
# Context estimates include system instructions and tool definitions.
# Set the window to the smallest backend context; leave room for generation.
CONTEXT_WINDOW_TOKENS=32768
PROMPT_BUDGET_TOKENS=16000
RESPONSE_RESERVE_TOKENS=4096
# Embedding Server Configuration
# If set, uses a custom OpenAI-compatible embedding server (e.g. llama-server)
+180 -14
View File
@@ -5,6 +5,7 @@ import os
import re
import time
import uuid
from urllib.parse import quote, unquote
from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import get_jwt_identity
@@ -16,9 +17,19 @@ from blueprints.users.decorators import jwt_access_required
from metrics import count_result, observe_response, time_response
from utils.image_process import image_content_block
from utils.strip_markdown import extract_agent_text
from utils.file_extract import (
FILE_TYPES,
FileExtractionError,
attachment_block,
extract_text,
file_extension,
)
from utils.image_upload import ALLOWED_TYPES as IMAGE_TYPES
from utils.image_upload import ImageValidationError, process_image
from utils.s3_client import generate_presigned_url as s3_presigned_url
from utils.s3_client import get_file as s3_get_file
from utils.s3_client import get_image as s3_get_image
from utils.s3_client import upload_file as s3_upload_file
from utils.s3_client import upload_image as s3_upload_image
from .logic import (
@@ -37,7 +48,35 @@ from .models import (
from .tool_events import tool_end_payload, tool_name_from_message, tool_start_payload
from .tool_labels import tool_label
TTFT_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
# Longest an in-flight model call may go without producing any output (prompt
# prefill included) before the turn is abandoned as stalled. Thinking tokens and
# empty chunks count as output; time spent running tools does not count at all.
MODEL_STALL_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
def stall_timeout(last_model_activity: float | None, now: float) -> float | None:
"""Seconds left before the in-flight model call counts as stalled.
None when no model call is in flight (e.g. while tools run): the httpx read
timeout and the tools' own timeouts cover those phases.
"""
if last_model_activity is None:
return None
return max(MODEL_STALL_TIMEOUT - (now - last_model_activity), 1.0)
def _model_call_summary(call: dict) -> dict:
"""Per-LLM-call timing and token counts for the stream_request log line."""
start = call["start"]
first_chunk = call.get("first_chunk")
end = call.get("end")
return {
"prefill_ms": round((first_chunk - start) * 1000) if first_chunk else None,
"ms": round((end - start) * 1000) if end else None,
"prompt_tokens": call.get("prompt_tokens"),
"output_tokens": call.get("output_tokens"),
}
_THINK_OPEN = "<think>"
_THINK_CLOSE = "</think>"
@@ -203,6 +242,87 @@ async def upload_image():
return jsonify({"image_key": key})
def _file_key_belongs_to(file_key: str, conversation_id) -> bool:
"""Only allow attachments stored under this conversation's prefix."""
return (
isinstance(file_key, str)
and ".." not in file_key
and file_key.startswith(f"conversations/{conversation_id}/files/")
)
async def _load_attached_file(file_key: str) -> tuple[str, str]:
"""Fetch an attached file from S3 and return (original name, extracted text)."""
data, metadata = await s3_get_file(file_key)
name = unquote(metadata.get("filename", "")) or file_key.rsplit("/", 1)[-1]
ext = file_extension(file_key, None) or ""
# PyMuPDF is CPU-bound; keep it off the event loop so other streams don't stall.
return name, await asyncio.to_thread(extract_text, data, ext)
@conversation_blueprint.post("/upload")
@jwt_access_required
async def upload_attachment():
current_user_uuid = get_jwt_identity()
files = await request.files
form = await request.form
file = files.get("file")
conversation_id = form.get("conversation_id")
if not file or not conversation_id:
return jsonify({"error": "file and conversation_id are required"}), 400
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
file_bytes = file.read()
content_type = (file.content_type or "").split(";")[0].strip().lower()
name = os.path.basename(file.filename or "") or "attachment"
if content_type in IMAGE_TYPES:
try:
processed_bytes, output_content_type = process_image(
file_bytes, content_type
)
except Exception as e:
return jsonify({"error": f"Could not process image: {e}"}), 400
ext = output_content_type.split("/")[-1]
if ext == "jpeg":
ext = "jpg"
key = f"conversations/{conversation.id}/{uuid.uuid4()}.{ext}"
await s3_upload_image(processed_bytes, key, output_content_type)
return jsonify({"kind": "image", "image_key": key})
ext = file_extension(name, content_type)
if ext is None:
return (
jsonify(
{
"error": f"Unsupported file type: {content_type or name}. "
"Allowed: images, PDF, TXT, MD, CSV"
}
),
415,
)
try:
text = await asyncio.to_thread(extract_text, file_bytes, ext)
except FileExtractionError as e:
return jsonify({"error": str(e)}), 400
if not text.strip():
message = (
"This PDF has no text layer (scanned?), so Simba can't read it yet."
if ext == "pdf"
else "This file is empty, so there's nothing for Simba to read."
)
return jsonify({"error": message}), 422
key = f"conversations/{conversation.id}/files/{uuid.uuid4()}.{ext}"
await s3_upload_file(file_bytes, key, FILE_TYPES[ext], {"filename": quote(name)})
return jsonify({"kind": "file", "file_key": key, "name": name, "chars": len(text)})
@conversation_blueprint.get("/image/<path:image_key>")
@jwt_access_required
async def serve_image(image_key: str):
@@ -219,12 +339,30 @@ async def stream_query():
query_text = data.get("query")
conversation_id = data.get("conversation_id")
image_key = data.get("image_key")
file_key = data.get("file_key")
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
# An attached file is extracted and appended to the agent's user turn; the
# saved message only records its name.
saved_text = query_text or ""
if file_key:
if not _file_key_belongs_to(file_key, conversation.id):
return jsonify({"error": "invalid file_key"}), 400
try:
file_name, file_text = await _load_attached_file(file_key)
block = attachment_block(file_name, file_text)
except Exception as e:
logging.error(f"Failed to load file {file_key}: {e}")
file_name = file_key.rsplit("/", 1)[-1]
block = "[A file was attached but could not be loaded.]"
query_text = f"{query_text or 'The user attached this file.'}\n\n{block}"
saved_text = f"{saved_text}\n\n📎 {file_name}".strip()
await add_message_to_conversation(
conversation=conversation,
message=query_text or "",
message=saved_text,
speaker="user",
user=user,
image_key=image_key,
@@ -271,6 +409,9 @@ async def stream_query():
first_content_at: float | None = None
tool_timers: dict[str, float] = {} # run_id -> start time
tool_spans: list[dict] = []
# Last output from the in-flight model call; None between calls.
last_model_activity: float | None = None
model_calls: dict[str, dict] = {} # run_id -> timing / token usage
n_tokens = 0
model_name: str | None = None
cancelled = False
@@ -293,6 +434,8 @@ async def stream_query():
ttft_ms = (
round((first_content_at - t0) * 1000) if first_content_at else None
)
calls = [_model_call_summary(c) for c in model_calls.values()]
prompt_tokens = [c["prompt_tokens"] for c in calls if c["prompt_tokens"]]
logging.info(
json.dumps(
{
@@ -304,6 +447,8 @@ async def stream_query():
"ttft_ms": ttft_ms,
"tokens": n_tokens,
"tools": tool_spans,
"model_calls": calls,
"max_prompt_tokens": max(prompt_tokens, default=None),
"model": model_name,
"cancelled": cancelled,
**({"error": error} if error else {}),
@@ -316,15 +461,10 @@ async def stream_query():
payload, version="v2", config=agent_config
).__aiter__()
while True:
# Apply a short TTFT timeout only during the window between
# on_chat_model_start and the first content token. Before the
# LLM call starts (tool phase) and after the first token, let
# the httpx read timeout handle stalls instead.
if llm_start_at is not None and first_content_at is None:
ttft_wait = TTFT_TIMEOUT - (time.monotonic() - llm_start_at)
timeout = max(ttft_wait, 1.0)
else:
timeout = None
# Only a silent model call counts as a stall. A model that is
# thinking (or streaming empty chunks) keeps resetting the clock,
# and each new call starts a fresh one.
timeout = stall_timeout(last_model_activity, time.monotonic())
try:
if timeout is not None:
@@ -336,7 +476,9 @@ async def stream_query():
except StopAsyncIteration:
break
except asyncio.TimeoutError:
_log_summary(error=f"TTFT timeout after {TTFT_TIMEOUT:.0f}s")
_log_summary(
error=f"model stalled: no output for {MODEL_STALL_TIMEOUT:.0f}s"
)
yield f"data: {json.dumps({'type': 'error', 'message': 'Simba took too long to respond. Please try again.', 'stalled': True})}\n\n"
return
@@ -388,14 +530,34 @@ async def stream_query():
elif event_type == "on_chat_model_start":
turn_has_content = False
now = time.monotonic()
last_model_activity = now
model_calls[event["run_id"]] = {"start": now}
if llm_start_at is None:
llm_start_at = time.monotonic()
llm_start_at = now
if model_name is None:
model_name = event.get("metadata", {}).get(
"ls_model_name"
) or event.get("name")
elif event_type == "on_chat_model_end":
last_model_activity = None
call = model_calls.get(event["run_id"])
if call is not None:
call["end"] = time.monotonic()
usage = getattr(
event.get("data", {}).get("output"), "usage_metadata", None
)
if usage:
call["prompt_tokens"] = usage.get("input_tokens")
call["output_tokens"] = usage.get("output_tokens")
elif event_type == "on_chat_model_stream":
now = time.monotonic()
last_model_activity = now
call = model_calls.get(event.get("run_id"))
if call is not None and "first_chunk" not in call:
call["first_chunk"] = now
chunk = event.get("data", {}).get("chunk")
raw = getattr(chunk, "content", None)
if isinstance(raw, str) and raw:
@@ -457,7 +619,7 @@ async def stream_query():
_log_summary()
yield "data: [DONE]\n\n"
return await make_response(
response = await make_response(
event_generator(),
200,
{
@@ -466,6 +628,10 @@ async def stream_query():
"X-Accel-Buffering": "no",
},
)
# Quart's default 60s response deadline cancels even active SSE streams.
# Silent model calls are bounded separately by MODEL_STALL_TIMEOUT.
response.timeout = None
return response
@conversation_blueprint.route("/<conversation_id>")
+42 -12
View File
@@ -1,4 +1,5 @@
import os
from functools import partial
from typing import cast
import httpx
@@ -8,6 +9,7 @@ from langchain.tools import tool
from langgraph.prebuilt import create_react_agent
from langchain_core.messages import SystemMessage
from langchain_core.runnables import RunnableConfig, RunnableLambda
from langchain_core.utils.function_calling import convert_to_openai_tool
from langchain_openai import ChatOpenAI
from tavily import AsyncTavilyClient
@@ -24,6 +26,7 @@ from blueprints.conversation.artifacts_util import (
truncate,
unique,
)
from blueprints.conversation.context import RESPONSE_RESERVE_TOKENS, build_context
from blueprints.conversation.memory import save_memory
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
from blueprints.rag.logic import query_vector_store
@@ -54,11 +57,14 @@ if llama_url:
timeout=httpx.Timeout(LLAMA_READ_TIMEOUT, connect=10.0),
max_retries=1,
model_kwargs={"reasoning_effort": "low"},
# Ask llama-server for token usage on the final chunk (prompt size logging).
stream_usage=True,
max_tokens=RESPONSE_RESERVE_TOKENS,
)
else:
llama_chat = None
openai_fallback = ChatOpenAI(model="gpt-5-mini")
openai_fallback = ChatOpenAI(model="gpt-5-mini", max_tokens=RESPONSE_RESERVE_TOKENS)
model_with_fallback = cast(
BaseChatModel,
llama_chat.with_fallbacks([openai_fallback]) if llama_chat else openai_fallback,
@@ -465,14 +471,22 @@ async def obsidian_search_notes(query: str):
@tool(response_format="content_and_artifact")
async def obsidian_read_note(relative_path: str):
"""Read the full content of a specific Obsidian note by path.
async def obsidian_read_note(
relative_path: str, start_line: int = 1, max_lines: int | None = None
):
"""Read an Obsidian note, optionally selecting a small range of content lines.
Use line ranges to retrieve omitted sections of a large note.
Args:
relative_path: Path relative to vault root (e.g., "notes/my-note.md")
start_line: First content line to read, starting at 1 (excludes frontmatter).
max_lines: Number of lines to read, or None for all remaining lines.
"""
if not obsidian_enabled:
return OBSIDIAN_NOT_CONFIGURED, None
if start_line < 1 or (max_lines is not None and max_lines < 1):
return "start_line and max_lines must be positive integers.", None
try:
note = obsidian_service.read_note(relative_path)
@@ -480,7 +494,25 @@ async def obsidian_read_note(relative_path: str):
result = f"File: {note['path']}\n\n"
result += f"Frontmatter:\n{content_data['metadata']}\n\n"
result += f"Content:\n{content_data['content']}\n\n"
content = content_data["content"]
if start_line != 1 or max_lines is not None:
lines = content.splitlines()
if start_line > len(lines):
return (
f"File: {note['path']} has {len(lines)} content lines; start_line is beyond the end.",
None,
)
end_line = (
min(len(lines), start_line - 1 + max_lines)
if max_lines is not None
else len(lines)
)
content = "\n".join(lines[start_line - 1 : end_line])
result += (
f"Content lines {start_line}-{end_line} of {len(lines)}:\n{content}\n\n"
)
else:
result += f"Content:\n{content}\n\n"
result += f"Tags: {', '.join(content_data['tags'])}\n"
result += f"Contains {len(content_data['wikilinks'])} wikilinks and {len(content_data['embeds'])} embeds"
@@ -1097,8 +1129,7 @@ async def beli_search_restaurants(
tools.append(beli_search_restaurants)
@RunnableLambda
def _build_prompt(state, config: RunnableConfig = None) -> list:
def _build_prompt(state, config: RunnableConfig = None, *, tool_schemas=()) -> list:
"""Inject the per-request system prompt, replacing any prior system messages."""
system_prompt = (
(config or {}).get("configurable", {}).get("system_prompt", SIMBA_SYSTEM_PROMPT)
@@ -1106,10 +1137,7 @@ def _build_prompt(state, config: RunnableConfig = None) -> list:
# LangGraph passes the full state dict {"messages": [...]}, not just the list
messages = state["messages"] if isinstance(state, dict) else state
non_system = [m for m in messages if not isinstance(m, SystemMessage)]
# Keep only the most recent messages to stay within the 32k context window.
# Tool call/result pairs each count as 2 messages, so 12 ≈ 4-6 conversational turns.
non_system = non_system[-12:]
return [SystemMessage(content=system_prompt)] + non_system
return build_context(non_system, system_prompt, list(tool_schemas))
def build_agent(checkpointer=None, extra_tools=()):
@@ -1118,9 +1146,11 @@ def build_agent(checkpointer=None, extra_tools=()):
``extra_tools`` are appended to the shared tool list, e.g. admin-only
Homebox and BookStack tools for the admin agent.
"""
agent_tools = tools + list(extra_tools)
tool_schemas = [convert_to_openai_tool(t) for t in agent_tools]
return create_react_agent(
model=model_with_fallback,
tools=tools + list(extra_tools),
prompt=_build_prompt,
tools=agent_tools,
prompt=RunnableLambda(partial(_build_prompt, tool_schemas=tool_schemas)),
checkpointer=checkpointer,
)
+286
View File
@@ -0,0 +1,286 @@
"""Budget the model's view of history without changing checkpointed messages.
Token estimates use UTF-8 bytes / 3 plus message overhead. They are deliberately
more conservative than the usual chars / 4 heuristic, but are not a model
specific tokenizer. A safety reserve covers estimation and chat-template costs.
"""
import json
import logging
import math
import os
from dotenv import load_dotenv
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
load_dotenv()
CONTEXT_WINDOW_TOKENS = int(os.getenv("CONTEXT_WINDOW_TOKENS", "32768"))
PROMPT_BUDGET_TOKENS = int(os.getenv("PROMPT_BUDGET_TOKENS", "16000"))
RESPONSE_RESERVE_TOKENS = int(os.getenv("RESPONSE_RESERVE_TOKENS", "4096"))
SAFETY_RESERVE_TOKENS = 2048
EARLIER_MESSAGE_MAX_CHARS = 4000
TOOL_RESULT_MAX_CHARS = 12000
TRUNCATION_NOTE = "\n[truncated: earlier context]"
IMAGE_PLACEHOLDER = "[image from an earlier message]"
def estimate_tokens(value) -> int:
text = (
value
if isinstance(value, str)
else json.dumps(value, ensure_ascii=False, default=str)
)
return math.ceil(len(text.encode("utf-8")) / 3)
def message_tokens(message: BaseMessage) -> int:
# Count tool arguments but never artifact payloads (not sent to the model).
content = message.content
images = 0
if isinstance(content, list):
images = sum(
isinstance(p, dict) and p.get("type") in ("image", "image_url")
for p in content
)
content = [
p
for p in content
if not (isinstance(p, dict) and p.get("type") in ("image", "image_url"))
]
return (
16
+ estimate_tokens(content)
+ images * 4096
+ estimate_tokens(getattr(message, "tool_calls", []))
)
def _cap(text: str, limit: int, note: str) -> str:
if len(text) <= limit:
return text
# Retain both ends: identifiers and totals often appear at the end of results.
head = limit * 3 // 4
return text[:head] + note + (text[-(limit - head) :] if limit > head else "")
def _shrink(message: BaseMessage, limit: int, *, earlier: bool) -> BaseMessage:
note = (
TRUNCATION_NOTE
if earlier
else "\n[tool result excerpt; omitted content can be fetched again with the source tool]\n"
)
content = message.content
if isinstance(content, str):
new_content = _cap(content, limit, note)
else:
new_content = []
remaining = limit
for part in content:
if (
isinstance(part, dict)
and part.get("type") in ("image_url", "image")
and earlier
):
new_content.append({"type": "text", "text": IMAGE_PLACEHOLDER})
elif isinstance(part, dict) and isinstance(part.get("text"), str):
text = _cap(part["text"], remaining, note)
new_content.append({**part, "text": text})
remaining = max(0, remaining - min(len(part["text"]), remaining))
elif isinstance(part, str):
new_content.append(_cap(part, remaining, note))
remaining = max(0, remaining - min(len(part), remaining))
else:
new_content.append(part)
return (
message
if new_content == content
else message.model_copy(update={"content": new_content})
)
def _turns(messages: list[BaseMessage]) -> list[list[BaseMessage]]:
turns = []
for message in messages:
if isinstance(message, HumanMessage):
turns.append([])
if turns:
turns[-1].append(message)
# Leading orphaned checkpoint fragments have no user question to anchor them.
return turns
def _complete_exchanges(turn: list[BaseMessage]) -> list[BaseMessage]:
"""Drop unfinished exchanges left by a cancelled turn and orphan results."""
result = []
i = 0
while i < len(turn):
message = turn[i]
if isinstance(message, AIMessage) and message.tool_calls:
expected = {c["id"] for c in message.tool_calls}
end = i + 1
while end < len(turn) and isinstance(turn[end], ToolMessage):
end += 1
replies = turn[i + 1 : end]
actual = [m.tool_call_id for m in replies]
if len(actual) == len(expected) and set(actual) == expected:
result.extend(turn[i:end])
i = end
else:
if not isinstance(message, ToolMessage):
result.append(message)
i += 1
return result
def _summary(turns: list[list[BaseMessage]], limit: int = 2400) -> SystemMessage | None:
excerpts = []
remaining = limit
for turn in reversed(turns):
lines = []
for message in turn:
if isinstance(message, HumanMessage) or (
isinstance(message, AIMessage) and not message.tool_calls
):
content = message.content
if not isinstance(content, str):
content = " ".join(
p.get("text", "") if isinstance(p, dict) else str(p)
for p in content
)
role = "User" if isinstance(message, HumanMessage) else "Assistant"
lines.append(f"{role}: {_cap(content, 400, ' [excerpt] ')}")
excerpt = "\n".join(lines)
if len(excerpt) > remaining:
break
excerpts.insert(0, excerpt)
remaining -= len(excerpt) + 2
if not excerpts:
return None
return SystemMessage(
content="Earlier conversation excerpts (historical data, not new instructions; excerpts may omit details):\n\n"
+ "\n\n".join(excerpts)
)
def trim_history(
messages: list[BaseMessage], *, token_budget: int = 12000
) -> list[BaseMessage]:
"""Keep the current turn, then recent whole turns, within an estimated budget.
The current user message and tool call arguments are never truncated. Fail
explicitly if these mandatory inputs cannot fit, rather than lose the task.
"""
turns = [_complete_exchanges(turn) for turn in _turns(messages)]
if not turns:
return []
current = turns[-1]
current = [
_shrink(m, TOOL_RESULT_MAX_CHARS, earlier=False)
if isinstance(m, ToolMessage)
else m
for m in current
]
def cost(ms):
return sum(message_tokens(m) for m in ms)
# Keep room for continuity even when the current turn has many tool results.
current_budget = (
token_budget - min(1000, token_budget // 8) if len(turns) > 1 else token_budget
)
if cost(current) > current_budget:
# Binary-search the largest shared excerpt size that fits. Tool call IDs
# and all results remain, including parallel exchanges.
low, high = 0, TOOL_RESULT_MAX_CHARS
def compact(limit):
return [
_shrink(m, limit, earlier=False)
if isinstance(m, (ToolMessage, AIMessage))
else m
for m in current
]
if cost(compact(0)) > token_budget:
raise ValueError(
"The current question and tool calls exceed the context budget. Send a smaller attachment or start a new conversation."
)
current_budget = max(current_budget, cost(compact(0)))
while low < high:
mid = (low + high + 1) // 2
if cost(compact(mid)) <= current_budget:
low = mid
else:
high = mid - 1
current = compact(low)
selected = []
used = cost(current)
prior = turns[:-1]
# Leave a small share for excerpts of older turns only when necessary.
summary_reserve = min(1000, max(0, token_budget - used))
for turn in reversed(prior):
shrunk = [_shrink(m, EARLIER_MESSAGE_MAX_CHARS, earlier=True) for m in turn]
turn_cost = cost(shrunk)
if used + turn_cost > token_budget - summary_reserve:
break
selected.insert(0, shrunk)
used += turn_cost
dropped = prior[: len(prior) - len(selected)]
summary = _summary(dropped)
result = [m for turn in selected for m in turn] + current
if summary and used + message_tokens(summary) <= token_budget:
result.insert(0, summary)
logging.info(
json.dumps(
{
"event": "context_budget",
"budget_tokens": token_budget,
"estimated_tokens": cost(result),
"input_messages": len(messages),
"output_messages": len(result),
"dropped_turns": len(dropped),
"summary_included": bool(
result and isinstance(result[0], SystemMessage)
),
}
)
)
return result
def build_context(
messages: list[BaseMessage], system_prompt: str, tool_schemas: list[dict]
) -> list[BaseMessage]:
limit = min(
PROMPT_BUDGET_TOKENS,
CONTEXT_WINDOW_TOKENS - RESPONSE_RESERVE_TOKENS - SAFETY_RESERVE_TOKENS,
)
system = SystemMessage(content=system_prompt)
budget = limit - message_tokens(system) - estimate_tokens(tool_schemas)
if budget <= 0:
raise ValueError(
"System instructions and tool definitions exceed the configured prompt budget."
)
history = trim_history(messages, token_budget=budget)
logging.info(
json.dumps(
{
"event": "prompt_budget",
"limit_tokens": limit,
"system_tokens": message_tokens(system),
"tool_schema_tokens": estimate_tokens(tool_schemas),
"estimated_prompt_tokens": message_tokens(system)
+ estimate_tokens(tool_schemas)
+ sum(message_tokens(m) for m in history),
"response_reserve_tokens": RESPONSE_RESERVE_TOKENS,
}
)
)
return [system] + history
+13 -1
View File
@@ -1,5 +1,8 @@
from .models import UserMemory
from .prompts import SIMBA_SYSTEM_PROMPT
from .context import estimate_tokens
MEMORY_BUDGET_TOKENS = 1000
async def get_memories_for_user(user_id: str) -> list[str]:
@@ -12,7 +15,16 @@ async def build_system_prompt_with_memories(user_id: str) -> str:
memories = await get_memories_for_user(user_id)
if not memories:
return SIMBA_SYSTEM_PROMPT
block = "\n".join(f"- {m}" for m in memories)
selected = []
used = 0
for memory in memories:
cost = estimate_tokens(f"- {memory}\n")
if used + cost <= MEMORY_BUDGET_TOKENS:
selected.append(memory)
used += cost
block = "\n".join(f"- {m}" for m in selected)
if len(selected) < len(memories):
block += "\n[Additional saved memories omitted from this prompt.]"
return f"{SIMBA_SYSTEM_PROMPT}\n\nUSER MEMORIES (facts the user has asked you to remember):\n{block}"
+37 -11
View File
@@ -110,8 +110,8 @@ WITH lines AS (
)
"""
# Spending = outflows (net of refunds) on budget accounts, excluding transfers
# (credit card payments, moves to savings) and income.
# Report cash flows on budget accounts, excluding transfers and internal
# categories. Separate outflows from inflows rather than calling their net spending.
SPENDING_FILTER = """
a.on_budget AND l.transfer_account_id IS NULL
AND NOT COALESCE(c.internal, false)
@@ -415,7 +415,7 @@ async def ynab_spending_report(
payee: str = "",
top: int = 15,
):
"""Total YNAB spending grouped by category, category_group, payee, account, or month. Use group_by='month' for trends over time.
"""Gross YNAB outflows grouped by category, category_group, payee, account, or month, with inflows reported separately. Use group_by='month' for trends over time.
Args:
group_by: category, category_group, payee, account, or month
@@ -436,6 +436,9 @@ async def ynab_spending_report(
except ValueError:
return "Dates must be YYYY-MM-DD."
if start > end:
return "start_date must be on or before end_date."
conditions, params = [SPENDING_FILTER, "l.date >= $1", "l.date <= $2"], [start, end]
if category:
params.append(f"%{category}%")
@@ -447,7 +450,7 @@ async def ynab_spending_report(
order = "key" if group_by == "month" else "spent DESC"
rows = await _query(
f"""{LINES_CTE}
SELECT {key} AS key, -SUM(l.amount) AS spent, count(*) AS n
SELECT {key} AS key, -SUM(LEAST(l.amount, 0)) AS spent, count(*) AS n
FROM lines l
JOIN ynab_accounts a ON a.id = l.account_id
LEFT JOIN ynab_categories c ON c.id = l.category_id
@@ -456,19 +459,38 @@ async def ynab_spending_report(
GROUP BY 1 ORDER BY {order}""",
*params,
)
if not rows:
return f"No spending found from {start} to {end}."
rows = [r for r in rows if r["spent"] > 0]
total = sum((r["spent"] for r in rows), Decimal(0))
totals = await _query(
f"""{LINES_CTE}
SELECT COALESCE(-SUM(LEAST(l.amount, 0)), 0) AS outflows,
COALESCE(SUM(GREATEST(l.amount, 0)), 0) AS inflows
FROM lines l
JOIN ynab_accounts a ON a.id = l.account_id
LEFT JOIN ynab_categories c ON c.id = l.category_id
WHERE {" AND ".join(conditions)}""",
*params,
)
outflows, inflows = totals[0]["outflows"], totals[0]["inflows"]
if not outflows and not inflows:
return f"No spending found from {start} to {end}."
top = max(1, top)
shown = rows if group_by == "month" else rows[:top]
lines = [f"Spending by {group_by} from {start} to {end}: total {_usd(total)}"]
lines = [
f"Spending by {group_by} from {start} to {end}:",
f"- Total spending (outflows): {_usd(outflows)}",
f"- Inflows (deposits into budget accounts, not spending): {_usd(inflows)}",
"- Group amounts are gross outflows; refunds and other inflows are reported separately.",
]
lines += [f"- {r['key']}: {_usd(r['spent'])} ({r['n']} txns)" for r in shown]
if len(rows) > len(shown):
rest = sum((r["spent"] for r in rows[len(shown) :]), Decimal(0))
lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}")
if group_by == "month" and len(rows) > 1:
lines.append(f"Monthly average: {_usd(total / len(rows))}")
months = (end.year - start.year) * 12 + end.month - start.month + 1
if group_by == "month":
lines.append(
f"Monthly average (outflows over {months} calendar months): {_usd(outflows / months)}"
)
artifact = make_artifact(
"ynab.spending_report",
f"Spending by {group_by.replace('_', ' ')} · "
@@ -482,7 +504,11 @@ async def ynab_spending_report(
{"key": r["key"], "amount": _amount(r["spent"]), "count": r["n"]}
for r in rows
],
"total": _amount(total),
"total": _amount(outflows),
"total_basis": "outflows",
"inflows": _amount(inflows),
"net_outflows": _amount(outflows - inflows),
"filters": {"category": category, "payee": payee},
}
),
)
+2
View File
@@ -146,6 +146,8 @@ pub fn run() {
.title("Simba")
.inner_size(1200.0, 820.0)
.min_inner_size(420.0, 520.0)
// Let HTML5 drag-and-drop reach the webview (file drop in chat).
.disable_drag_drop_handler()
.build()?;
build_tray(app.handle())?;
register_hotkey(app.handle())?;
+43
View File
@@ -0,0 +1,43 @@
# Conversation context management
The LangGraph checkpoint retains original messages and artifacts. Each model
call constructs a bounded view; compaction never rewrites the checkpoint.
The prompt budget includes system instructions, saved memories, tool schemas
(including admin tools), message content and tool arguments. The default target
is 16,000 estimated tokens. A 4,096-token generation limit and 2,048-token safety
reserve also constrain the budget against the configured 32,768-token window.
Estimates use UTF-8 bytes divided by three, message overhead, and a fixed image
allowance. These are estimates rather than model-specific tokenizer counts.
Compare `prompt_budget.estimated_prompt_tokens` with actual `stream_request`
model-call usage and adjust the target/window for the deployed backend.
The current user request is always retained. Oversized tool results become
marked excerpts preserving their beginning and end; all tool IDs and arguments
remain. If necessary, intermediate assistant text is also shortened. If the
mandatory request and exchange structure cannot fit, the request fails with an
explicit error rather than silently dropping the question.
Recent history is selected as complete turns. Older user messages, assistant
answers and tool results are capped; earlier images become placeholders.
Incomplete tool exchanges from cancelled calls and orphan results are removed
from the prompt. A small budget is reserved for bounded question/answer excerpts
from older turns. These excerpts are not an LLM-generated rolling summary and do
not guarantee retention of every historical decision. No extra model call is
added to the request path. Saved memories use a separate 1,000-token estimated
budget, preferring recent entries without deleting omitted memories.
Obsidian reads accept `start_line` (one-based content line, excluding frontmatter)
and `max_lines`, allowing focused follow-up reads. Full artifact content remains
available to the interface and checkpoint.
Configuration:
- `CONTEXT_WINDOW_TOKENS`: smallest supported context window across backends.
- `PROMPT_BUDGET_TOKENS`: prompt target, including fixed instructions and tools.
- `RESPONSE_RESERVE_TOKENS`: maximum generated tokens and reserved answer space.
`context_budget` logs history estimates, message counts, dropped turns and excerpt
inclusion. `prompt_budget` logs fixed overhead and the complete prompt estimate.
Neither log includes message content.
+15 -5
View File
@@ -51,6 +51,12 @@ interface Conversation {
user_id?: string;
}
export type UploadResult =
| { kind: "image"; image_key: string }
| { kind: "file"; file_key: string; name: string; chars: number };
export class UploadError extends Error {}
interface QueryResponse {
response: string;
}
@@ -182,16 +188,16 @@ class ConversationService {
return await response.json();
}
async uploadImage(
async uploadAttachment(
file: File,
conversationId: string,
): Promise<{ image_key: string }> {
): Promise<UploadResult> {
const formData = new FormData();
formData.append("file", file);
formData.append("conversation_id", conversationId);
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/upload-image`,
`${this.conversationBaseUrl}/upload`,
{
method: "POST",
body: formData,
@@ -200,8 +206,8 @@ class ConversationService {
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to upload image");
const data = await response.json().catch(() => ({}));
throw new UploadError(data.error || "Failed to upload file");
}
return await response.json();
@@ -224,11 +230,15 @@ class ConversationService {
onEvent: SSEEventCallback,
signal?: AbortSignal,
imageKey?: string,
fileKey?: string,
): Promise<void> {
const body: Record<string, string> = { query, conversation_id };
if (imageKey) {
body.image_key = imageKey;
}
if (fileKey) {
body.file_key = fileKey;
}
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/stream-query`,
+18 -12
View File
@@ -6,6 +6,7 @@ import { AnswerBubble } from "./AnswerBubble";
import { ActivityTimeline } from "./ActivityTimeline";
import { ThinkingBubble } from "./ThinkingBubble";
import { MessageInput } from "./MessageInput";
import { DropOverlay } from "./DropOverlay";
import { SidebarContent } from "./SidebarContent";
import { AdminPanel } from "./AdminPanel";
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
@@ -84,8 +85,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
streaming,
thinking,
messagesLoading,
pendingImage,
setPendingImage,
pendingAttachment,
attachFile,
clearAttachment,
attachmentError,
clearAttachmentError,
sendMessage,
stopGeneration,
error,
@@ -141,9 +145,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
[deleteConversation],
);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
const handleLogout = () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
@@ -223,7 +224,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
<div className="relative flex-1 flex flex-col h-full overflow-hidden min-w-0">
<DropOverlay onDrop={attachFile} />
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
<div className="flex items-center gap-2">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
@@ -302,9 +304,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
onToggleSimbaMode={handleToggleSimbaMode}
isLoading={isLoading}
onStop={stopGeneration}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
pendingAttachment={pendingAttachment}
onAttach={attachFile}
onClearAttachment={clearAttachment}
attachmentError={attachmentError}
onDismissAttachmentError={clearAttachmentError}
autoFocus
/>
</div>
@@ -431,9 +435,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
onToggleSimbaMode={handleToggleSimbaMode}
isLoading={isLoading}
onStop={stopGeneration}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
pendingAttachment={pendingAttachment}
onAttach={attachFile}
onClearAttachment={clearAttachment}
attachmentError={attachmentError}
onDismissAttachmentError={clearAttachmentError}
/>
</div>
</footer>
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import { Paperclip } from "lucide-react";
const hasFiles = (e: DragEvent) =>
Array.from(e.dataTransfer?.types ?? []).includes("Files");
type DropOverlayProps = {
onDrop: (file: File) => void;
};
/**
* Listens for file drags anywhere in the window and covers its positioned
* parent (the chat column, beside the artifact panel) with a "Drop to attach"
* target. Below lg the panel is a full-screen sheet, so it covers the window
* and sits above the sheet. Drags without files (text, links)
* are ignored so they keep their normal behaviour.
*/
export const DropOverlay = ({ onDrop }: DropOverlayProps) => {
const [active, setActive] = useState(false);
// dragenter/dragleave fire for every element crossed; count to know when
// the drag has really left the window.
const depthRef = useRef(0);
const onDropRef = useRef(onDrop);
onDropRef.current = onDrop;
useEffect(() => {
const handleEnter = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depthRef.current += 1;
setActive(true);
};
const handleOver = (e: DragEvent) => {
if (!hasFiles(e)) return;
// Required for the drop event to fire (and to stop the browser
// navigating to the file).
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
};
const handleLeave = (e: DragEvent) => {
if (!hasFiles(e)) return;
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setActive(false);
};
const handleDrop = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depthRef.current = 0;
setActive(false);
const file = e.dataTransfer?.files?.[0];
if (file) onDropRef.current(file);
};
window.addEventListener("dragenter", handleEnter);
window.addEventListener("dragover", handleOver);
window.addEventListener("dragleave", handleLeave);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleEnter);
window.removeEventListener("dragover", handleOver);
window.removeEventListener("dragleave", handleLeave);
window.removeEventListener("drop", handleDrop);
};
}, []);
if (!active) return null;
return (
<div
className="pointer-events-none fixed lg:absolute inset-0 z-[45] flex items-center justify-center bg-cream/80 backdrop-blur-sm p-3 md:p-5"
data-testid="drop-overlay"
>
<div className="w-full h-full rounded-3xl border-2 border-dashed border-amber-glow/50 bg-amber-pale/70 flex flex-col items-center justify-center gap-3 message-enter">
<div className="w-14 h-14 rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30 text-amber-glow flex items-center justify-center">
<Paperclip size={24} />
</div>
<p
className="text-lg font-bold text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
Drop to attach
</p>
<p className="text-xs text-warm-gray">Images, PDF, TXT, MD or CSV · up to 10 MB</p>
</div>
</div>
);
};
+87 -16
View File
@@ -1,6 +1,12 @@
import React, { useEffect, useMemo, useRef } from "react";
import { ArrowUp, ImagePlus, Square, X } from "lucide-react";
import { ArrowUp, FileSpreadsheet, FileText, Paperclip, Square, X } from "lucide-react";
import { cn } from "../lib/utils";
import {
ATTACHMENT_ACCEPT,
attachmentKind,
fileExtension,
formatBytes,
} from "../lib/attachments";
import { Textarea } from "./ui/textarea";
type MessageInputProps = {
@@ -12,9 +18,11 @@ type MessageInputProps = {
query: string;
isLoading: boolean;
onStop: () => void;
pendingImage: File | null;
onImageSelect: (file: File) => void;
onClearImage: () => void;
pendingAttachment: File | null;
onAttach: (file: File) => void;
onClearAttachment: () => void;
attachmentError: string | null;
onDismissAttachmentError: () => void;
autoFocus?: boolean;
};
@@ -28,15 +36,23 @@ export const MessageInput = React.memo(
onToggleSimbaMode,
isLoading,
onStop,
pendingImage,
onImageSelect,
onClearImage,
pendingAttachment,
onAttach,
onClearAttachment,
attachmentError,
onDismissAttachmentError,
autoFocus,
}: MessageInputProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Create blob URL once per file, revoke on cleanup
const pendingImage =
pendingAttachment && attachmentKind(pendingAttachment) === "image"
? pendingAttachment
: null;
const pendingFile = pendingAttachment && !pendingImage ? pendingAttachment : null;
// Create blob URL once per image, revoke on cleanup
const previewUrl = useMemo(
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
[pendingImage],
@@ -59,7 +75,7 @@ export const MessageInput = React.memo(
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onImageSelect(file);
onAttach(file);
}
// Reset so the same file can be re-selected
e.target.value = "";
@@ -69,19 +85,21 @@ export const MessageInput = React.memo(
if (isLoading) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
e.preventDefault();
onImageSelect(file);
onAttach(file);
return;
}
}
}
};
const canSend = !isLoading && Boolean(query.trim() || pendingImage);
const canSend = !isLoading && Boolean(query.trim() || pendingAttachment);
const FileIcon =
pendingFile && fileExtension(pendingFile.name) === "csv" ? FileSpreadsheet : FileText;
return (
<div
@@ -102,7 +120,8 @@ export const MessageInput = React.memo(
/>
<button
type="button"
onClick={onClearImage}
onClick={onClearAttachment}
aria-label="Remove attachment"
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
>
<X size={12} />
@@ -111,6 +130,56 @@ export const MessageInput = React.memo(
</div>
)}
{/* File chip */}
{pendingFile && (
<div className="px-3 pt-3">
<div
className="relative inline-flex items-center gap-2.5 max-w-full rounded-xl border border-sand bg-cream pl-2 pr-7 py-2 message-enter"
data-testid="attachment-chip"
>
<div className="w-8 h-8 shrink-0 rounded-lg bg-amber-pale text-amber-dark flex items-center justify-center">
<FileIcon size={16} />
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-charcoal truncate max-w-[14rem] sm:max-w-xs">
{pendingFile.name}
</div>
<div className="text-[11px] text-warm-gray">
{fileExtension(pendingFile.name).toUpperCase() || "File"} ·{" "}
{formatBytes(pendingFile.size)}
</div>
</div>
<button
type="button"
onClick={onClearAttachment}
aria-label="Remove attachment"
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full text-warm-gray flex items-center justify-center hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
>
<X size={12} />
</button>
</div>
</div>
)}
{attachmentError && (
<div className="px-3 pt-3">
<div
role="alert"
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600 message-enter"
>
<span>{attachmentError}</span>
<button
type="button"
onClick={onDismissAttachmentError}
className="text-red-400 hover:text-red-600 transition-colors cursor-pointer"
aria-label="Dismiss error"
>
<X size={13} />
</button>
</div>
</div>
)}
{/* Textarea */}
<Textarea
ref={textareaRef}
@@ -128,7 +197,7 @@ export const MessageInput = React.memo(
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept={ATTACHMENT_ACCEPT}
onChange={handleFileChange}
className="hidden"
/>
@@ -150,11 +219,13 @@ export const MessageInput = React.memo(
</span>
</button>
{/* Image attach button */}
{/* Attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
aria-label="Attach a file"
title="Attach an image, PDF, TXT, MD or CSV"
className={cn(
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
isLoading
@@ -162,7 +233,7 @@ export const MessageInput = React.memo(
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<ImagePlus size={16} />
<Paperclip size={16} />
</button>
</div>
@@ -1,5 +1,7 @@
import React from "react";
import { FileSpreadsheet, FileText } from "lucide-react";
import { cn } from "../lib/utils";
import { fileExtension, splitAttachedFile } from "../lib/attachments";
import { usePresignedUrl } from "../hooks/usePresignedUrl";
type QuestionBubbleProps = {
@@ -9,6 +11,8 @@ type QuestionBubbleProps = {
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
const { imageUrl, imageError } = usePresignedUrl(image_key);
const { body, fileName } = splitAttachedFile(text);
const FileIcon = fileName && fileExtension(fileName) === "csv" ? FileSpreadsheet : FileText;
return (
<div className="flex justify-end message-enter">
@@ -32,7 +36,21 @@ export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubblePro
className="max-w-full rounded-xl mb-2"
/>
)}
{text}
{fileName && (
<div
className={cn(
"flex items-center gap-1.5 w-fit max-w-full rounded-lg",
"bg-warm-white/70 border border-leaf-light/70 px-2 py-1",
"text-xs text-leaf-dark",
body && "mb-2",
)}
data-testid="message-attachment"
>
<FileIcon size={13} className="shrink-0" />
<span className="truncate">{fileName}</span>
</div>
)}
{body}
</div>
</div>
);
@@ -88,12 +88,15 @@ export const YnabSpendingReport = ({ data }: RendererProps<YnabSpendingReportDat
<Subtle>Grouped by {data.group_by?.replace(/_/g, " ")}</Subtle>
</div>
<div className="text-right">
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">Total</div>
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">{data.total_basis === "outflows" ? "Total outflows" : "Total"}</div>
<div className="text-xl font-bold tabular-nums text-charcoal">{formatMoney(data.total)}</div>
{data.inflows != null && <Subtle>Inflows: {formatMoney(data.inflows)}</Subtle>}
{data.net_outflows != null && <Subtle>Net outflows: {formatMoney(data.net_outflows)}</Subtle>}
</div>
</div>
{data.total_basis === "outflows" && <Subtle>Groups show gross outflows. Refunds and other inflows are reported separately.</Subtle>}
{rows.length === 0 ? (
<EmptyNote>No spending in this period.</EmptyNote>
<EmptyNote>No outflows in this period.</EmptyNote>
) : data.group_by === "month" ? (
<MonthBars rows={rows} />
) : (
@@ -87,9 +87,13 @@ export type YnabSpendingReportData = {
start_date: string;
end_date: string;
group_by: "category" | "category_group" | "payee" | "account" | "month" | string;
/** amount is positive for spending (-SUM); a net refund comes out negative. */
/** Gross outflows for new reports; legacy reports contain net spending. */
rows: { key: string; amount: number; count?: number }[];
total: number;
total_basis?: "outflows";
inflows?: number;
net_outflows?: number;
filters?: { category: string; payee: string };
};
export type YnabCategoryBudgetData = {
+44 -12
View File
@@ -2,9 +2,11 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useMatch } from "react-router-dom";
import {
conversationService,
UploadError,
type ToolInput,
} from "../api/conversationService";
import type { ArtifactRef } from "../api/artifactTypes";
import { validateAttachment, withAttachedFile } from "../lib/attachments";
import type { Conversation } from "./useConversations";
export type ToolStatus = "running" | "ok" | "error";
@@ -63,7 +65,9 @@ export function useChat({
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
// One image or text-like file to send with the next message.
const [pendingAttachment, setPendingAttachment] = useState<File | null>(null);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [stalled, setStalled] = useState(false);
const [streaming, setStreaming] = useState(false);
@@ -164,7 +168,7 @@ export function useChat({
const sendMessage = useCallback(
async (query: string, simbaMode: boolean) => {
if ((!query.trim() && !pendingImage) || isLoading) return;
if ((!query.trim() && !pendingAttachment) || isLoading) return;
let activeId = conversationId;
if (!activeId) {
@@ -174,7 +178,7 @@ export function useChat({
activeId = created.id;
}
const imageFile = pendingImage;
const attachment = pendingAttachment;
setError(null);
setStalled(false);
@@ -185,7 +189,8 @@ export function useChat({
streamingActiveRef.current = false;
setStreaming(false);
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
setPendingImage(null);
setPendingAttachment(null);
setAttachmentError(null);
setIsLoading(true);
if (simbaMode) {
@@ -203,19 +208,27 @@ export function useChat({
try {
let imageKey: string | undefined;
if (imageFile) {
const uploadResult = await conversationService.uploadImage(
imageFile,
let fileKey: string | undefined;
if (attachment) {
const uploadResult = await conversationService.uploadAttachment(
attachment,
activeId,
);
imageKey = uploadResult.image_key;
let patch: Partial<TextMessage>;
if (uploadResult.kind === "image") {
imageKey = uploadResult.image_key;
patch = { image_key: imageKey };
} else {
fileKey = uploadResult.file_key;
patch = { text: withAttachedFile(query, uploadResult.name) };
}
updateMessages((prev) => {
const updated = [...prev];
for (let i = updated.length - 1; i >= 0; i--) {
const msg = updated[i];
if (msg.speaker === "user") {
updated[i] = { ...msg, image_key: imageKey };
updated[i] = { ...msg, ...patch };
break;
}
}
@@ -342,10 +355,13 @@ export function useChat({
},
abortController.signal,
imageKey,
fileKey,
);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// User cancelled — not an error to surface.
} else if (err instanceof UploadError) {
if (isMountedRef.current) setError(`Couldn't attach that file: ${err.message}`);
} else {
console.error("Failed to send query:", err);
if (
@@ -383,7 +399,7 @@ export function useChat({
}
},
[
pendingImage,
pendingAttachment,
isLoading,
conversationId,
createConversation,
@@ -400,6 +416,19 @@ export function useChat({
const clearError = useCallback(() => setError(null), []);
const attachFile = useCallback((file: File) => {
const problem = validateAttachment(file);
setAttachmentError(problem);
if (!problem) setPendingAttachment(file);
}, []);
const clearAttachment = useCallback(() => {
setPendingAttachment(null);
setAttachmentError(null);
}, []);
const clearAttachmentError = useCallback(() => setAttachmentError(null), []);
const retryStalled = useCallback(() => {
const q = stalledQueryRef.current;
if (!q) return;
@@ -415,8 +444,11 @@ export function useChat({
streaming,
thinking,
messagesLoading,
pendingImage,
setPendingImage,
pendingAttachment,
attachFile,
clearAttachment,
attachmentError,
clearAttachmentError,
sendMessage,
stopGeneration,
error,
+63
View File
@@ -0,0 +1,63 @@
// What can be attached to a message: images go to the vision model, text-like
// files are extracted server-side and appended to the turn.
export const ATTACHMENT_ACCEPT = "image/*,.pdf,.txt,.md,.markdown,.csv";
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
]);
const FILE_EXTENSIONS = new Set(["pdf", "txt", "md", "markdown", "csv"]);
const FILE_TYPES = new Set([
"application/pdf",
"text/plain",
"text/markdown",
"text/x-markdown",
"text/csv",
]);
export type AttachmentKind = "image" | "file";
export const fileExtension = (name: string) =>
name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
export function attachmentKind(file: File): AttachmentKind | null {
if (IMAGE_TYPES.has(file.type)) return "image";
if (FILE_TYPES.has(file.type) || FILE_EXTENSIONS.has(fileExtension(file.name))) {
return "file";
}
return null;
}
/** Returns an error message if the file can't be attached, else null. */
export function validateAttachment(file: File): string | null {
if (!attachmentKind(file)) {
return `Can't attach “${file.name}”. Use an image, PDF, TXT, MD or CSV file.`;
}
if (file.size > MAX_ATTACHMENT_BYTES) {
return `“${file.name}” is larger than 10 MB.`;
}
return null;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Saved user messages record an attached file as a trailing "📎 name" line.
const ATTACHED_FILE_RE = /(?:^|\n\n)📎 ([^\n]+)$/u;
export const withAttachedFile = (text: string, name: string) =>
text ? `${text}\n\n📎 ${name}` : `📎 ${name}`;
export function splitAttachedFile(text: string): { body: string; fileName: string | null } {
const match = text.match(ATTACHED_FILE_RE);
if (!match) return { body: text, fileName: null };
return { body: text.slice(0, match.index), fileName: match[1] };
}
@@ -20,8 +20,16 @@
// (all v1 kinds) | "broken" (GET /api/artifacts/<id> 500s) | "mystery"
// (unknown kind → JSON fallback)
// anything else: two sequential calls, then a streamed answer
// Conversations "seeded" (timeline) and "everything" (one saved tool message
// per artifact kind) exist up front.
// any query sent with a file_key: reads the attached file (by name)
// Conversations "seeded" (timeline), "everything" (one saved tool message
// per artifact kind) and "attachment" (a saved 📎 file message) exist up front.
//
// Uploads (POST /api/conversation/upload) follow the contract: JPEG/PNG/WebP/
// HEIC -> {kind:"image"}, PDF/TXT/MD/CSV (by MIME or extension) ->
// {kind:"file"}, anything else 415, files named *scan*.pdf 422 (no text
// layer). Uploaded images are served back via /api/conversation/image/<key>.
// GET /__mock/requests returns recorded API request bodies (multipart files
// summarised as {filename, type, size}), newest last.
// artifacts/<kind>.json holds one ToolArtifact per v1 kind, matching what the
// backend tools emit. Any other *.json in this directory with `kind` and
// `data` is loaded as an artifact of "seeded" (`id` defaults to the file name).
@@ -62,6 +70,10 @@ const uuid = () => crypto.randomUUID();
const artifacts = new Map();
/** @type {Map<string, {id, name, created_at, updated_at, messages: any[]}>} */
const conversations = new Map();
/** Uploaded objects by S3-style key: {bytes, type, name} */
const blobs = new Map();
/** Recorded API requests: {method, path, body} */
const requests = [];
function addArtifact(conversationId, tool, a) {
const record = {
@@ -278,8 +290,15 @@ const LABELS = {
};
const label = (tool) => LABELS[tool] ?? `Using ${tool}…`;
function seedAttachment() {
const c = newConversation("attachment", "Vet bill questions");
pushMessage(c, { speaker: "user", text: "How much was the dental cleaning?\n\n📎 vet-invoice-2026-09.pdf" });
pushMessage(c, { speaker: "simba", text: "The dental cleaning on the September invoice was **$412.50**." });
}
seed();
seedEverything();
seedAttachment();
loadFixtureArtifacts();
/** Artifact-panel scenarios: one tool call per artifact, by keyword. */
@@ -304,11 +323,20 @@ function artifactScenario(q) {
* Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded
* into contract-shaped events; ctx persists tool messages and artifacts.
*/
function scenario(query) {
function scenario(query, file) {
const q = query.toLowerCase();
const answer = (text) =>
text.match(/\S+\s*/g).map((w) => [60, { type: "content", delta: w }]);
if (file) {
return [
[200, { type: "thinking_delta", delta: `Reading the attached ${file.name}…` }],
[300, { start: "f1", tool: "simba_search", input: { query: (query || file.name).slice(0, 200) } }],
[600, { end: "f1" }],
...answer(`I read **${file.name}**. It looks like a vet invoice; the dental cleaning came to $412.50.`),
];
}
if (q.includes("plain")) {
return [[300, { type: "thinking" }], ...answer("Meow. Nothing to look up for that one.")];
}
@@ -363,8 +391,17 @@ function scenario(query) {
async function streamQuery(req, res, body) {
const c = conversations.get(body.conversation_id) ?? newConversation(body.conversation_id);
pushMessage(c, { speaker: "user", text: body.query, image_key: body.image_key ?? null });
if (c.name === "New Conversation") c.name = body.query.slice(0, 40);
let saved = body.query ?? "";
let file = null;
if (body.file_key) {
file = blobs.get(body.file_key);
if (!body.file_key.startsWith(`conversations/${c.id}/files/`) || !file) {
return json(res, 400, { error: "invalid file_key" });
}
saved = `${saved}\n\n📎 ${file.name}`.trim();
}
pushMessage(c, { speaker: "user", text: saved, image_key: body.image_key ?? null });
if (c.name === "New Conversation") c.name = (body.query || file?.name || "Attachment").slice(0, 40);
res.writeHead(200, {
"Content-Type": "text/event-stream",
@@ -377,7 +414,7 @@ async function streamQuery(req, res, body) {
const runs = new Map(); // key -> {run_id, tool, started}
let text = "";
for (const [delay, step] of scenario(body.query)) {
for (const [delay, step] of scenario(body.query ?? "", file)) {
await sleep(delay);
if (closed) return;
if (step.start) {
@@ -428,24 +465,100 @@ const json = (res, status, body) => {
res.end(JSON.stringify(body));
};
const readBody = (req) =>
new Promise((resolve) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
try {
resolve(raw ? JSON.parse(raw) : {});
} catch {
resolve({});
}
});
});
async function readRaw(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}
/** Minimal multipart/form-data parser: {fields, files: {name: {filename, type, data}}}. */
function parseMultipart(buf, contentType) {
const boundary = /boundary=(?:"([^"]+)"|([^;]+))/.exec(contentType ?? "");
const out = { fields: {}, files: {} };
if (!boundary) return out;
const delim = Buffer.from(`--${boundary[1] ?? boundary[2]}`);
let pos = buf.indexOf(delim);
while (pos !== -1) {
const start = pos + delim.length + 2; // skip CRLF
const next = buf.indexOf(delim, start);
if (next === -1) break;
const part = buf.subarray(start, next - 2); // strip trailing CRLF
const headerEnd = part.indexOf("\r\n\r\n");
const headers = part.subarray(0, headerEnd).toString("utf8");
const data = part.subarray(headerEnd + 4);
const name = /name="([^"]*)"/.exec(headers)?.[1];
const filename = /filename="([^"]*)"/.exec(headers)?.[1];
if (name && filename !== undefined) {
const type = /Content-Type:\s*([^\r\n]+)/i.exec(headers)?.[1]?.trim() ?? "";
out.files[name] = { filename, type, data };
} else if (name) {
out.fields[name] = data.toString("utf8");
}
pos = next;
}
return out;
}
/** Read the body once: JSON, or multipart form as {fields, files}. */
async function readBody(req) {
const raw = await readRaw(req);
const type = req.headers["content-type"] ?? "";
if (type.startsWith("multipart/form-data")) return { form: parseMultipart(raw, type) };
try {
return raw.length ? JSON.parse(raw.toString("utf8")) : {};
} catch {
return {};
}
}
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]);
const FILE_TYPES = { "application/pdf": "pdf", "text/plain": "txt", "text/markdown": "md", "text/csv": "csv" };
const FILE_EXTS = new Set(["pdf", "txt", "md", "csv"]);
function upload(res, form) {
const file = form?.files.file;
const conversationId = form?.fields.conversation_id;
if (!file || !conversationId) return json(res, 400, { error: "file and conversation_id are required" });
if (!conversations.has(conversationId)) return json(res, 404, { error: "conversation not found" });
if (file.data.length > 10 * 1024 * 1024) return json(res, 413, { error: "File too large" });
const type = file.type.split(";")[0].trim().toLowerCase();
if (IMAGE_TYPES.has(type)) {
const key = `conversations/${conversationId}/${uuid()}.${type.split("/")[1].replace("jpeg", "jpg")}`;
blobs.set(key, { bytes: file.data, type, name: file.filename });
return json(res, 200, { kind: "image", image_key: key });
}
const fromName = path.extname(file.filename).slice(1).toLowerCase();
const ext = FILE_TYPES[type] ?? (FILE_EXTS.has(fromName) ? fromName : null);
if (!ext) {
return json(res, 415, { error: `Unsupported file type: ${type || file.filename}. Allowed: images, PDF, TXT, MD, CSV` });
}
if (ext === "pdf" && /scan/i.test(file.filename)) {
return json(res, 422, { error: "This PDF has no text layer (scanned?), so Simba can't read it yet." });
}
const key = `conversations/${conversationId}/files/${uuid()}.${ext}`;
blobs.set(key, { bytes: file.data, type, name: file.filename });
return json(res, 200, { kind: "file", file_key: key, name: file.filename, chars: ext === "pdf" ? 1834 : file.data.length });
}
const summary = (c) => ({ id: c.id, name: c.name, created_at: c.created_at, updated_at: c.updated_at });
async function api(req, res, url) {
const p = url.pathname.replace(/\/+$/, "") || "/";
const m = req.method;
const body = m === "GET" ? {} : await readBody(req);
const { form } = body;
requests.push({
method: m,
path: url.pathname + url.search,
body: form
? {
...form.fields,
files: Object.fromEntries(
Object.entries(form.files).map(([k, f]) => [k, { filename: f.filename, type: f.type, size: f.data.length }]),
),
}
: body,
});
if (p === "/api/user/me") {
return json(res, 200, { id: "u1", username: "ryan", email: "ryan@example.com", is_admin: true });
@@ -456,16 +569,18 @@ async function api(req, res, url) {
if (p.startsWith("/api/scheduled")) return json(res, 200, []);
if (p === "/api/conversation/stream-query" && m === "POST") {
return streamQuery(req, res, await readBody(req));
return streamQuery(req, res, body);
}
if (p === "/api/conversation/query" && m === "POST") {
return json(res, 200, { response: "meow (mock)" });
}
if (p === "/api/conversation/upload-image" || p === "/api/conversation/upload") {
return json(res, 200, { kind: "image", image_key: `mock/${uuid()}.png` });
if (p === "/api/conversation/upload" && m === "POST") return upload(res, form);
if (p === "/api/conversation/upload-image") {
return json(res, 200, { image_key: `mock/${uuid()}.png` });
}
if (p.startsWith("/api/conversation/image/")) {
return json(res, 200, { url: "/favicon.ico" });
const key = decodeURIComponent(p.slice("/api/conversation/image/".length));
return json(res, 200, { url: blobs.has(key) ? `/__mock/blob/${encodeURIComponent(key)}` : "/favicon.ico" });
}
if (p === "/api/conversation") {
if (m === "POST") return json(res, 200, summary(newConversation()));
@@ -490,7 +605,7 @@ async function api(req, res, url) {
return json(res, 200, { success: true });
}
if (m === "PATCH") {
c.name = (await readBody(req)).name ?? c.name;
c.name = body.name ?? c.name;
return json(res, 200, summary(c));
}
return json(res, 200, {
@@ -547,6 +662,13 @@ http
.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
try {
if (url.pathname === "/__mock/requests") return json(res, 200, requests);
if (url.pathname.startsWith("/__mock/blob/")) {
const blob = blobs.get(decodeURIComponent(url.pathname.slice("/__mock/blob/".length)));
if (!blob) return json(res, 404, { error: "no blob" });
res.writeHead(200, { "Content-Type": blob.type });
return res.end(blob.bytes);
}
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
return serveStatic(res, decodeURIComponent(url.pathname));
} catch (err) {
@@ -0,0 +1,37 @@
const fs = require('node:fs');
const assert = require('node:assert/strict');
const test = require('node:test');
const ts = require('typescript');
const React = require('react');
const { renderToStaticMarkup } = require('react-dom/server');
// Load the existing TSX renderer without adding a test bundler dependency.
for (const ext of ['.ts', '.tsx']) {
require.extensions[ext] = (mod, filename) => {
const { outputText } = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
});
mod._compile(outputText, filename);
};
}
const { YnabSpendingReport } = require('../src/components/artifacts/renderers/YnabSpendingReport.tsx');
const base = { start_date: '2026-10-01', end_date: '2026-10-12', group_by: 'category', rows: [{ key: 'Shopping', amount: 70, count: 2 }], total: 100 };
const render = (data) => renderToStaticMarkup(React.createElement(YnabSpendingReport, { data }));
test('legacy artifact keeps a neutral total label', () => {
const html = render(base);
assert.match(html, />Total</);
assert.doesNotMatch(html, /Total \(net\)|Total outflows|Inflows:/);
});
test('new report labels gross outflows, inflows and net separately', () => {
const html = render({ ...base, total_basis: 'outflows', inflows: 30, net_outflows: 70 });
assert.match(html, /Total outflows/);
assert.match(html, /Inflows:/);
assert.match(html, /Net outflows:/);
assert.match(html, /Groups show gross outflows/);
});
test('inflow-only report remains visible with empty outflow rows', () => {
const html = render({ ...base, total_basis: 'outflows', rows: [], total: 0, inflows: 75, net_outflows: -75 });
assert.match(html, /No outflows in this period/);
assert.match(html, /Inflows:/);
});
+272
View File
@@ -0,0 +1,272 @@
"""Regression tests for complete-turn context budgeting."""
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from blueprints.conversation import context
from blueprints.conversation.context import (
IMAGE_PLACEHOLDER,
build_context,
message_tokens,
trim_history,
)
def call(*ids):
return AIMessage(
content="",
tool_calls=[
{"id": i, "name": "search", "args": {"path": "notes/budget.md"}}
for i in ids
],
)
def result(i, content="found"):
return ToolMessage(content=content, tool_call_id=i)
def cost(messages):
return sum(message_tokens(m) for m in messages)
def test_long_current_turn_keeps_question_and_all_exchanges():
question = HumanMessage(content="Compare my spending with my plan")
messages = [question]
for i in range(8):
messages += [call(str(i)), result(str(i), "data " * 1000)]
out = trim_history(messages, token_budget=3000)
assert out[0] is question
assert len(out) == 17
assert cost(out) <= 3000
assert [m.tool_call_id for m in out if isinstance(m, ToolMessage)] == [
str(i) for i in range(8)
]
assert messages[2].content == "data " * 1000
assert out[1].tool_calls == messages[1].tool_calls
def test_parallel_tool_results_remain_paired():
messages = [
HumanMessage(content="q"),
call("a", "b"),
result("a", "x" * 20000),
result("b", "y" * 20000),
]
out = trim_history(messages, token_budget=700)
assert len(out) == 4
assert out[1].tool_calls == messages[1].tool_calls
assert cost(out) <= 700
def test_drops_whole_turns_and_includes_bounded_excerpts():
messages = []
for i in range(12):
messages += [
HumanMessage(content=f"Question {i}"),
call(str(i)),
result(str(i), "data " * 1000),
AIMessage(content=f"Decision {i}"),
]
question = HumanMessage(content="What did we decide?")
messages.append(question)
out = trim_history(messages, token_budget=3000)
assert isinstance(out[0], SystemMessage)
assert "Decision" in out[0].content
assert "historical data" in out[0].content
assert cost(out) <= 3000
assert out[-1] is question
# Every retained result still has its original call preceding it.
seen = set()
for m in out:
if isinstance(m, AIMessage):
seen.update(c["id"] for c in m.tool_calls)
if isinstance(m, ToolMessage):
assert m.tool_call_id in seen
def test_earlier_images_replaced_current_image_preserved():
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
messages = [
HumanMessage(content=[{"type": "text", "text": "old"}, image]),
AIMessage(content="photo"),
HumanMessage(content=[{"type": "text", "text": "new"}, image]),
]
out = trim_history(messages)
assert out[0].content[1] == {"type": "text", "text": IMAGE_PLACEHOLDER}
assert out[-1].content[1] == image
assert messages[0].content[1] == image
def test_short_history_unchanged():
messages = [HumanMessage(content="hi"), AIMessage(content="hello")]
assert trim_history(messages) == messages
def test_question_that_cannot_fit_fails_without_truncation():
messages = [HumanMessage(content="important request " * 1000)]
with pytest.raises(ValueError, match="current question"):
trim_history(messages, token_budget=100)
assert messages[0].content == "important request " * 1000
def test_system_and_tool_schemas_are_charged_to_budget(monkeypatch):
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 2500)
monkeypatch.setattr(context, "CONTEXT_WINDOW_TOKENS", 10000)
schemas = [{"name": "search", "description": "tool docs " * 100}]
messages = [HumanMessage(content="q"), call("a"), result("a", "result " * 3000)]
out = build_context(messages, "system " * 100, schemas)
assert cost(out) + context.estimate_tokens(schemas) <= 2500
assert out[1] is messages[0]
def test_fixed_overhead_that_cannot_fit_fails(monkeypatch):
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 100)
with pytest.raises(ValueError, match="tool definitions"):
build_context([HumanMessage(content="q")], "system" * 1000, [])
def test_response_and_safety_reserves_reduce_prompt_limit(monkeypatch):
monkeypatch.setattr(context, "CONTEXT_WINDOW_TOKENS", 8000)
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 16000)
messages = [HumanMessage(content="q"), call("a"), result("a", "data " * 5000)]
out = build_context(messages, "system", [])
assert (
cost(out) + context.estimate_tokens([])
<= 8000 - context.RESPONSE_RESERVE_TOKENS - context.SAFETY_RESERVE_TOKENS
)
def test_cancelled_turn_does_not_replay_incomplete_exchange():
messages = [
HumanMessage(content="old"),
call("a", "b"),
result("a"),
HumanMessage(content="new"),
]
out = trim_history(messages)
assert out == [messages[0], messages[-1]]
def test_orphan_results_are_removed():
messages = [
result("orphan"),
HumanMessage(content="q"),
result("another orphan"),
AIMessage(content="answer"),
]
assert trim_history(messages) == [messages[1], messages[3]]
def test_large_assistant_answer_is_bounded_in_earlier_turn():
messages = [
HumanMessage(content="old"),
AIMessage(content="long " * 10000),
HumanMessage(content="new"),
]
out = trim_history(messages, token_budget=4000)
assert cost(out) <= 4000
assert len(out[1].content) < len(messages[1].content)
assert messages[1].content == "long " * 10000
def test_multiblock_attachment_shares_one_cap():
messages = [
HumanMessage(content=[{"type": "text", "text": "x" * 10000} for _ in range(5)]),
AIMessage(content="ok"),
HumanMessage(content="next"),
]
out = trim_history(messages, token_budget=5000)
assert cost(out) <= 5000
assert sum(len(p["text"]) for p in out[0].content) < 5000
async def test_saved_memories_are_bounded_without_deleting(monkeypatch):
from blueprints.conversation import memory
saved = ["newest preference", "big " * 5000, "older preference"]
async def get_memories(user_id):
return saved
monkeypatch.setattr(memory, "get_memories_for_user", get_memories)
prompt = await memory.build_system_prompt_with_memories("user")
block = prompt.split("USER MEMORIES", 1)[1]
assert "newest preference" in block
assert "older preference" in block
assert "omitted" in block
assert "big big" not in block
assert saved[1] == "big " * 5000
def test_agent_prompt_accounts_for_extra_tool_schemas(monkeypatch):
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
from blueprints.conversation import agents
captured = {}
def create(**kwargs):
captured.update(kwargs)
monkeypatch.setattr(agents, "create_react_agent", create)
extra = {
"type": "function",
"function": {
"name": "extra",
"description": "extra docs",
"parameters": {"type": "object", "properties": {}},
},
}
agents.build_agent(extra_tools=[extra])
assert extra in captured["tools"]
question = HumanMessage(content="hello")
actual = captured["prompt"].invoke({"messages": [question]})
assert actual[-1] is question
# The actual prompt closure includes this schema rather than relying on a
# static reserve that would miss admin-only tools.
seen = {}
def build(messages, system, schemas):
seen["schemas"] = schemas
return messages
monkeypatch.setattr(agents, "build_context", build)
messages = [HumanMessage(content="hello")]
assert captured["prompt"].invoke({"messages": messages}) == messages
assert extra in seen["schemas"]
def test_heavy_current_turn_leaves_room_for_previous_decisions():
question = HumanMessage(content="Apply that plan to my spending")
messages = [
HumanMessage(content="Plan?"),
AIMessage(content="Keep dining under $300."),
question,
call("a"),
result("a", "spending " * 10000),
]
out = trim_history(messages, token_budget=2500)
assert cost(out) <= 2500
assert question in out
assert any(
"Keep dining under $300." in m.content
for m in out
if isinstance(m.content, str)
)
def test_large_tool_arguments_fail_explicitly_instead_of_breaking_pair():
exchange = call("a")
exchange.tool_calls[0]["args"] = {"query": "huge " * 10000}
messages = [HumanMessage(content="q"), exchange, result("a")]
with pytest.raises(ValueError, match="tool calls exceed"):
trim_history(messages, token_budget=300)
assert exchange.tool_calls[0]["args"]["query"] == "huge " * 10000
def test_unicode_tool_result_fits_estimated_budget():
messages = [HumanMessage(content="q"), call("a"), result("a", "猫😺预算" * 10000)]
out = trim_history(messages, token_budget=600)
assert cost(out) <= 600
assert out[0] is messages[0]
assert out[1].tool_calls == messages[1].tool_calls
+282
View File
@@ -0,0 +1,282 @@
"""Tests for conversation file attachments: extraction, /upload and stream-query file_key."""
import io
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import fitz
import pytest
from PIL import Image
from quart import Quart
from quart.datastructures import FileStorage
from quart_jwt_extended import JWTManager, create_access_token
import blueprints.conversation as conv
from utils.file_extract import (
MAX_ATTACHMENT_CHARS,
TRUNCATION_MARKER,
FileExtractionError,
attachment_block,
cap_text,
extract_text,
file_extension,
)
CONV_ID = "11111111-1111-1111-1111-111111111111"
OTHER_ID = "22222222-2222-2222-2222-222222222222"
def _pdf_bytes(text: str) -> bytes:
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), text)
data = doc.tobytes()
doc.close()
return data
def _png_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (4, 4), "orange").save(buf, format="PNG")
return buf.getvalue()
class TestFileExtension:
@pytest.mark.parametrize(
"name,mime,expected",
[
("a.pdf", "application/pdf", "pdf"),
("notes", "text/markdown", "md"),
("notes.md", "application/octet-stream", "md"),
("data.CSV", "", "csv"),
("x.txt", "text/plain; charset=utf-8", "txt"),
("x.markdown", None, "md"),
("x.docx", "application/vnd.openxmlformats", None),
("x.zip", "application/zip", None),
],
)
def test_detects_supported_types(self, name, mime, expected):
assert file_extension(name, mime) == expected
class TestExtractText:
def test_pdf(self):
assert "Simba eats kibble" in extract_text(
_pdf_bytes("Simba eats kibble"), "pdf"
)
def test_corrupt_pdf_raises(self):
with pytest.raises(FileExtractionError):
extract_text(b"not a pdf", "pdf")
def test_text_decodes_utf8_with_replacement(self):
assert extract_text("héllo".encode() + b"\xff", "txt") == "héllo�"
def test_csv_and_md_are_plain_text(self):
assert extract_text(b"\xef\xbb\xbfa,b\n1,2", "csv") == "a,b\n1,2"
assert extract_text(b"# Title", "md") == "# Title"
class TestTruncation:
def test_short_text_untouched(self):
assert cap_text("abc") == "abc"
def test_long_text_capped_with_marker(self):
out = cap_text("x" * (MAX_ATTACHMENT_CHARS + 50))
assert out.endswith(TRUNCATION_MARKER)
assert out.count("x") == MAX_ATTACHMENT_CHARS
def test_attachment_block_wraps_and_caps(self):
block = attachment_block('a"b.txt', "y" * (MAX_ATTACHMENT_CHARS + 1))
assert block.startswith('<attached file "a\'b.txt">\n')
assert block.endswith(f"{TRUNCATION_MARKER}\n</attached>")
class TestFileKeyPrefix:
def test_own_conversation(self):
assert conv._file_key_belongs_to(
f"conversations/{CONV_ID}/files/x.pdf", CONV_ID
)
@pytest.mark.parametrize(
"key",
[
f"conversations/{OTHER_ID}/files/x.pdf",
f"conversations/{CONV_ID}/x.jpg",
f"conversations/{CONV_ID}/files/../../{OTHER_ID}/files/x.pdf",
f"conversations/{CONV_ID}",
None,
],
)
def test_rejects_other_keys(self, key):
assert not conv._file_key_belongs_to(key, CONV_ID)
@pytest.fixture
def app():
app = Quart(__name__)
app.config["JWT_SECRET_KEY"] = "test-secret"
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
JWTManager(app)
app.register_blueprint(conv.conversation_blueprint)
return app
@pytest.fixture
async def headers(app):
async with app.app_context():
token = create_access_token(identity="user-1")
return {"Authorization": f"Bearer {token}"}
def _owned(conversation_id, user_id):
if conversation_id == CONV_ID and user_id == "user-1":
return SimpleNamespace(id=CONV_ID)
return None
@pytest.fixture
def s3():
with (
patch.object(conv, "_get_owned_conversation", AsyncMock(side_effect=_owned)),
patch.object(conv, "s3_upload_file", AsyncMock()) as upload_file,
patch.object(conv, "s3_upload_image", AsyncMock()) as upload_image,
):
yield SimpleNamespace(upload_file=upload_file, upload_image=upload_image)
async def _upload(
app, headers, data: bytes, filename: str, mime: str, conversation_id=CONV_ID
):
return await app.test_client().post(
"/api/conversation/upload",
headers=headers,
form={"conversation_id": conversation_id},
files={
"file": FileStorage(io.BytesIO(data), filename=filename, content_type=mime)
},
)
class TestUploadRoute:
async def test_pdf_is_stored_with_filename(self, app, headers, s3):
resp = await _upload(
app, headers, _pdf_bytes("hello"), "Vet bill.pdf", "application/pdf"
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["kind"] == "file"
assert body["name"] == "Vet bill.pdf"
assert body["chars"] >= len("hello")
assert body["file_key"].startswith(f"conversations/{CONV_ID}/files/")
assert body["file_key"].endswith(".pdf")
_, key, mime, metadata = s3.upload_file.await_args.args
assert key == body["file_key"] and mime == "application/pdf"
assert metadata == {"filename": "Vet%20bill.pdf"}
async def test_markdown_by_extension(self, app, headers, s3):
resp = await _upload(app, headers, b"# hi", "n.md", "application/octet-stream")
body = await resp.get_json()
assert resp.status_code == 200
assert body["chars"] == 4 and body["file_key"].endswith(".md")
async def test_image_goes_through_process_image(self, app, headers, s3):
resp = await _upload(app, headers, _png_bytes(), "cat.png", "image/png")
assert resp.status_code == 200
body = await resp.get_json()
assert body["kind"] == "image"
assert body["image_key"].startswith(f"conversations/{CONV_ID}/")
s3.upload_image.assert_awaited_once()
s3.upload_file.assert_not_awaited()
async def test_unsupported_type_is_415(self, app, headers, s3):
resp = await _upload(app, headers, b"PK\x03\x04", "a.zip", "application/zip")
assert resp.status_code == 415
s3.upload_file.assert_not_awaited()
async def test_unsupported_image_type_is_415(self, app, headers, s3):
resp = await _upload(app, headers, b"GIF89a", "a.gif", "image/gif")
assert resp.status_code == 415
async def test_corrupt_pdf_is_400(self, app, headers, s3):
resp = await _upload(app, headers, b"nope", "a.pdf", "application/pdf")
assert resp.status_code == 400
async def test_pdf_without_text_layer_is_422(self, app, headers, s3):
doc = fitz.open()
doc.new_page() # blank page: no text, like a scan without OCR
resp = await _upload(app, headers, doc.tobytes(), "scan.pdf", "application/pdf")
assert resp.status_code == 422
assert "no text layer" in (await resp.get_json())["error"]
s3.upload_file.assert_not_awaited()
async def test_empty_text_file_is_422(self, app, headers, s3):
resp = await _upload(app, headers, b" \n", "empty.txt", "text/plain")
assert resp.status_code == 422
s3.upload_file.assert_not_awaited()
async def test_extraction_runs_off_the_event_loop(self, app, headers, s3):
calls = []
async def fake_to_thread(fn, *args):
calls.append(fn)
return fn(*args)
with patch.object(conv.asyncio, "to_thread", fake_to_thread):
resp = await _upload(
app, headers, _pdf_bytes("hi"), "a.pdf", "application/pdf"
)
get = AsyncMock(return_value=(b"x", {}))
with patch.object(conv, "s3_get_file", get):
await conv._load_attached_file(f"conversations/{CONV_ID}/files/u.txt")
assert resp.status_code == 200
assert calls == [conv.extract_text, conv.extract_text]
async def test_other_users_conversation_is_404(self, app, headers, s3):
resp = await _upload(
app, headers, b"hi", "a.txt", "text/plain", conversation_id=OTHER_ID
)
assert resp.status_code == 404
s3.upload_file.assert_not_awaited()
async def test_requires_auth(self, app, s3):
resp = await _upload(app, {}, b"hi", "a.txt", "text/plain")
assert resp.status_code == 401
class TestStreamQueryFileKey:
async def test_rejects_file_key_from_other_conversation(self, app, headers, s3):
add = AsyncMock()
with (
patch.object(conv.blueprints.users.models.User, "get", AsyncMock()),
patch.object(conv, "add_message_to_conversation", add),
):
resp = await app.test_client().post(
"/api/conversation/stream-query",
headers=headers,
json={
"query": "summarise",
"conversation_id": CONV_ID,
"file_key": f"conversations/{OTHER_ID}/files/x.pdf",
},
)
assert resp.status_code == 400
add.assert_not_awaited()
async def test_load_attached_file_recovers_name_and_text(self):
get = AsyncMock(return_value=(b"a,b\n1,2", {"filename": "M%C3%A9nage.csv"}))
with patch.object(conv, "s3_get_file", get):
name, text = await conv._load_attached_file(
f"conversations/{CONV_ID}/files/u.csv"
)
assert name == "Ménage.csv"
assert text == "a,b\n1,2"
async def test_load_attached_file_falls_back_to_key_name(self):
get = AsyncMock(return_value=(b"x", {}))
with patch.object(conv, "s3_get_file", get):
name, _ = await conv._load_attached_file(
f"conversations/{CONV_ID}/files/u.txt"
)
assert name == "u.txt"
+220
View File
@@ -0,0 +1,220 @@
"""Tests for the stream-query stall timeout and per-call model metrics."""
import asyncio
import json
import logging
import pytest
from langchain_core.messages import AIMessage, AIMessageChunk
from quart import Quart
from quart_jwt_extended import JWTManager, create_access_token
from tortoise import Tortoise
import blueprints.conversation as conv
from blueprints.conversation.models import Conversation
from blueprints.users.models import User
# Shortened stall limit for the tests (seconds).
LIMIT = 0.3
@pytest.fixture
async def owner():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={
"models": [
"blueprints.users.models",
"blueprints.conversation.models",
"blueprints.artifacts.models",
]
},
)
await Tortoise.generate_schemas()
user = await User.create(username="owner", email="owner@example.com")
conversation = await Conversation.create(name="c", user=user)
yield user, conversation
await Tortoise.close_connections()
class _TimedAgent:
"""Yields (delay_seconds, event) pairs, sleeping before each event."""
def __init__(self, script):
self._script = script
async def astream_events(self, payload, version, config):
for delay, event in self._script:
await asyncio.sleep(delay)
yield event
def _start(run_id):
return {"event": "on_chat_model_start", "run_id": run_id, "name": "llm", "data": {}}
def _chunk(run_id, text):
return {
"event": "on_chat_model_stream",
"run_id": run_id,
"data": {"chunk": AIMessageChunk(content=text)},
}
def _end(run_id, prompt_tokens=None):
usage = (
{
"input_tokens": prompt_tokens,
"output_tokens": 5,
"total_tokens": prompt_tokens + 5,
}
if prompt_tokens
else None
)
return {
"event": "on_chat_model_end",
"run_id": run_id,
"data": {"output": AIMessage(content="", usage_metadata=usage)},
}
def _final(text):
return {
"event": "on_chain_end",
"name": "agent",
"data": {"output": {"messages": [AIMessage(content=text)]}},
}
async def _run(owner, monkeypatch, script, *, response_timeout=60):
user, conversation = owner
monkeypatch.setattr(conv, "MODEL_STALL_TIMEOUT", LIMIT)
monkeypatch.setattr(conv, "get_agent_for", lambda u: _TimedAgent(script))
async def _prompt(user_id):
return "system"
monkeypatch.setattr(conv, "build_system_prompt_with_memories", _prompt)
app = Quart(__name__)
app.config["RESPONSE_TIMEOUT"] = response_timeout
app.config["JWT_SECRET_KEY"] = "test-secret"
JWTManager(app)
app.register_blueprint(conv.conversation_blueprint)
async with app.app_context():
token = create_access_token(identity=str(user.id))
resp = await app.test_client().post(
"/api/conversation/stream-query",
json={"query": "hi", "conversation_id": str(conversation.id)},
headers={"Authorization": f"Bearer {token}"},
)
body = (await resp.get_data()).decode()
return [
json.loads(line[len("data: ") :])
for line in body.splitlines()
if line.startswith("data: {")
]
def test_stall_timeout_only_while_a_model_call_is_in_flight(monkeypatch):
monkeypatch.setattr(conv, "MODEL_STALL_TIMEOUT", 30.0)
assert conv.stall_timeout(None, 100.0) is None
assert conv.stall_timeout(100.0, 110.0) == 20.0
assert conv.stall_timeout(100.0, 200.0) == 1.0 # floor
async def test_active_stream_outlasts_response_timeout(owner, monkeypatch):
script = [
(0, _start("m1")),
(0, _chunk("m1", "<think>")),
(0.15, _chunk("m1", "working")),
(0.15, _chunk("m1", "</think>Answer")),
(0, _end("m1")),
(0, _final("Answer")),
]
frames = await _run(owner, monkeypatch, script, response_timeout=0.1)
assert "error" not in [f["type"] for f in frames]
assert frames[-1] == {"type": "response", "message": "Answer"}
async def test_long_thinking_is_not_a_stall(owner, monkeypatch):
# The whole call takes ~4x the limit, but output never pauses for longer
# than half of it; the old time-to-first-content deadline killed this.
script = [(0, _start("m1"))]
script += [(LIMIT / 2, _chunk("m1", "<think>hmm ")) for _ in range(8)]
script += [
(0, _chunk("m1", "</think>Answer")),
(0, _end("m1")),
(0, _final("Answer")),
]
frames = await _run(owner, monkeypatch, script)
types = [f["type"] for f in frames]
assert "error" not in types
assert frames[-1] == {"type": "response", "message": "Answer"}
async def test_tool_time_between_model_calls_does_not_count(owner, monkeypatch):
tool = {"event": "on_tool_start", "name": "web_search", "run_id": "t1", "data": {}}
tool_end = {
"event": "on_tool_end",
"name": "web_search",
"run_id": "t1",
"data": {},
}
script = [
(0, _start("m1")),
(0, _chunk("m1", "")),
(0, _end("m1")),
(0, tool),
(LIMIT * 3, tool_end), # slow tool, no model call in flight
(0, _start("m2")),
(LIMIT / 2, _chunk("m2", "Done")),
(0, _end("m2")),
(0, _final("Done")),
]
frames = await _run(owner, monkeypatch, script)
assert "error" not in [f["type"] for f in frames]
assert frames[-1]["message"] == "Done"
async def test_silent_model_call_is_a_stall(owner, monkeypatch):
script = [
(0, _start("m1")),
(0, _chunk("m1", "<think>start")),
(LIMIT * 4, _chunk("m1", "never arrives")),
]
frames = await _run(owner, monkeypatch, script)
assert frames[-1]["type"] == "error"
assert frames[-1]["stalled"] is True
async def test_logs_per_call_prompt_tokens(owner, monkeypatch, caplog):
script = [
(0, _start("m1")),
(0, _chunk("m1", "Hi")),
(0, _end("m1", prompt_tokens=27262)),
(0, _final("Hi")),
]
with caplog.at_level(logging.INFO):
await _run(owner, monkeypatch, script)
summaries = [
json.loads(r.getMessage())
for r in caplog.records
if r.getMessage().startswith('{"event": "stream_request"')
]
assert summaries, "stream_request summary not logged"
summary = summaries[-1]
assert summary["max_prompt_tokens"] == 27262
(call,) = summary["model_calls"]
assert call["prompt_tokens"] == 27262
assert call["prefill_ms"] is not None and call["ms"] is not None
+23
View File
@@ -589,3 +589,26 @@ def test_search_documents_artifact_empty():
)
def test_format_date_range(start, end, expected):
assert format_date_range(start, end) == expected
async def test_obsidian_note_line_range_retains_full_artifact(obsidian):
with open(os.path.join(obsidian.vault_path, "range.md"), "w") as f:
f.write("first\nsecond\nthird\nfourth")
msg = await call(
agents.obsidian_read_note,
{"relative_path": "range.md", "start_line": 2, "max_lines": 2},
)
assert "Content lines 2-3 of 4:\nsecond\nthird\n\n" in msg.content
assert "first" not in msg.content
assert msg.artifact["data"]["markdown"] == "first\nsecond\nthird\nfourth"
@pytest.mark.parametrize(
"args", [{"start_line": 0}, {"max_lines": 0}, {"start_line": 99}]
)
async def test_obsidian_note_invalid_line_range(obsidian, args):
with open(os.path.join(obsidian.vault_path, "range.md"), "w") as f:
f.write("first\nsecond")
msg = await call(agents.obsidian_read_note, {"relative_path": "range.md", **args})
assert msg.artifact is None
assert "positive integers" in msg.content or "beyond the end" in msg.content
+136 -11
View File
@@ -26,7 +26,17 @@ KIND_DATA_KEYS = {
"overspent",
},
"ynab.transactions": {"start_date", "end_date", "count", "total", "transactions"},
"ynab.spending_report": {"start_date", "end_date", "group_by", "rows", "total"},
"ynab.spending_report": {
"start_date",
"end_date",
"group_by",
"rows",
"total",
"total_basis",
"inflows",
"net_outflows",
"filters",
},
"ynab.category_budget": {"month", "groups"},
}
@@ -240,20 +250,31 @@ async def test_search_transactions_bad_date(monkeypatch):
# --- ynab_spending_report ---------------------------------------------------
SPENDING_ROWS = [
{"key": "Groceries", "spent": Decimal("400.00"), "n": 10},
{"key": "Groceries", "spent": Decimal("500.00"), "n": 10},
{"key": "Dining Out", "spent": Decimal("150.50"), "n": 5},
{"key": "Gas", "spent": Decimal("60.00"), "n": 2},
]
async def test_spending_report(monkeypatch):
fake_db(monkeypatch, {"GROUP BY 1": SPENDING_ROWS})
fake_db(
monkeypatch,
{
"GROUP BY 1": SPENDING_ROWS,
"AS outflows": [
{"outflows": Decimal("710.50"), "inflows": Decimal("100.00")}
],
},
)
msg = await call(tools.ynab_spending_report, {"top": 2})
assert msg.content == (
"Spending by category from 2026-10-01 to 2026-10-12: total $610.50\n"
"- Groceries: $400.00 (10 txns)\n"
"Spending by category from 2026-10-01 to 2026-10-12:\n"
"- Total spending (outflows): $710.50\n"
"- Inflows (deposits into budget accounts, not spending): $100.00\n"
"- Group amounts are gross outflows; refunds and other inflows are reported separately.\n"
"- Groceries: $500.00 (10 txns)\n"
"- Dining Out: $150.50 (5 txns)\n"
"- 1 others: $60.00"
)
@@ -265,32 +286,53 @@ async def test_spending_report(monkeypatch):
"end_date": "2026-10-12",
"group_by": "category",
"rows": [
{"key": "Groceries", "amount": 400.0, "count": 10},
{"key": "Groceries", "amount": 500.0, "count": 10},
{"key": "Dining Out", "amount": 150.5, "count": 5},
{"key": "Gas", "amount": 60.0, "count": 2},
],
"total": 610.5,
"total": 710.5,
"total_basis": "outflows",
"inflows": 100.0,
"net_outflows": 610.5,
"filters": {"category": "", "payee": ""},
}
async def test_spending_report_by_month(monkeypatch):
rows = [
{"key": "2026-09", "spent": Decimal("100"), "n": 1},
{"key": "2026-10", "spent": Decimal("200"), "n": 2},
{"key": "2026-10", "spent": Decimal("300"), "n": 2},
]
fake_db(monkeypatch, {"GROUP BY 1": rows})
fake_db(
monkeypatch,
{
"GROUP BY 1": rows,
"AS outflows": [
{"outflows": Decimal("400.00"), "inflows": Decimal("100.00")}
],
},
)
msg = await call(
tools.ynab_spending_report,
{"group_by": "month", "start_date": "2026-09-01"},
)
assert msg.content.endswith("Monthly average: $150.00")
assert msg.content.endswith(
"Monthly average (outflows over 2 calendar months): $200.00"
)
assert msg.artifact["data"]["total"] == 400.0
assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"]
async def test_spending_report_empty_and_invalid(monkeypatch):
fake_db(monkeypatch, {"GROUP BY 1": []})
fake_db(
monkeypatch,
{
"GROUP BY 1": [],
"AS outflows": [{"outflows": Decimal(0), "inflows": Decimal(0)}],
},
)
msg = await call(tools.ynab_spending_report, {})
assert msg.content == "No spending found from 2026-10-01 to 2026-10-12."
@@ -425,3 +467,86 @@ async def test_db_error(monkeypatch, tool):
def test_text_only_tools_unchanged():
assert tools.ynab_account_balances.response_format == "content"
assert tools.ynab_recent_changes.response_format == "content"
async def test_spending_report_omits_nonpositive_groups(monkeypatch):
rows = SPENDING_ROWS + [
{"key": "Uncategorized", "spent": Decimal("-5000"), "n": 1},
{"key": "Fully refunded", "spent": Decimal("0"), "n": 2},
]
fake_db(
monkeypatch,
{
"GROUP BY 1": rows,
"AS outflows": [
{"outflows": Decimal("710.50"), "inflows": Decimal("5100.00")}
],
},
)
msg = await call(tools.ynab_spending_report, {})
assert "Total spending (outflows): $710.50" in msg.content
assert "not spending): $5,100.00" in msg.content
assert "Uncategorized" not in msg.content
assert "Fully refunded" not in msg.content
assert [r["key"] for r in msg.artifact["data"]["rows"]] == [
r["key"] for r in SPENDING_ROWS
]
assert msg.artifact["data"]["total"] == 710.5
async def test_spending_report_fully_refunded_purchase_is_not_empty(monkeypatch):
queries = fake_db(
monkeypatch,
{
"GROUP BY 1": [{"key": "Shopping", "spent": Decimal(100), "n": 2}],
"AS outflows": [{"outflows": Decimal(100), "inflows": Decimal(100)}],
},
)
msg = await call(tools.ynab_spending_report, {"category": "Shopping"})
assert msg.artifact["data"]["total"] == 100
assert msg.artifact["data"]["net_outflows"] == 0
assert msg.artifact["data"]["rows"][0]["amount"] == 100
assert "-SUM(LEAST(l.amount, 0)) AS spent" in queries[0][0]
assert all("l.category_name ILIKE $3" in sql for sql, _ in queries)
assert msg.artifact["data"]["filters"] == {"category": "Shopping", "payee": ""}
async def test_spending_report_inflow_only_period_retains_totals(monkeypatch):
fake_db(
monkeypatch,
{
"GROUP BY 1": [{"key": "Refund", "spent": Decimal(0), "n": 1}],
"AS outflows": [{"outflows": Decimal(0), "inflows": Decimal(75)}],
},
)
msg = await call(tools.ynab_spending_report, {})
assert msg.artifact["data"]["rows"] == []
assert msg.artifact["data"]["total"] == 0
assert msg.artifact["data"]["inflows"] == 75
assert "not spending): $75.00" in msg.content
async def test_monthly_average_includes_zero_spending_months(monkeypatch):
fake_db(
monkeypatch,
{
"GROUP BY 1": [{"key": "2026-10", "spent": Decimal(300), "n": 1}],
"AS outflows": [{"outflows": Decimal(300), "inflows": Decimal(0)}],
},
)
msg = await call(
tools.ynab_spending_report, {"group_by": "month", "start_date": "2026-08-01"}
)
assert msg.content.endswith(
"Monthly average (outflows over 3 calendar months): $100.00"
)
async def test_spending_report_reversed_dates_are_rejected(monkeypatch):
queries = fake_db(monkeypatch, {})
msg = await call(
tools.ynab_spending_report,
{"start_date": "2026-11-01", "end_date": "2026-10-01"},
)
assert msg.content == "start_date must be on or before end_date."
assert queries == []
+62
View File
@@ -0,0 +1,62 @@
"""Text extraction for files attached to a conversation (PDF, txt, md, csv)."""
import os
import fitz
# Extension -> canonical MIME type for the text-like files we accept.
FILE_TYPES = {
"pdf": "application/pdf",
"txt": "text/plain",
"md": "text/markdown",
"csv": "text/csv",
}
_MIME_TO_EXT = {mime: ext for ext, mime in FILE_TYPES.items()}
_MIME_TO_EXT["text/x-markdown"] = "md"
MAX_ATTACHMENT_CHARS = 12_000
TRUNCATION_MARKER = "[truncated]"
class FileExtractionError(Exception):
pass
def file_extension(filename: str | None, content_type: str | None) -> str | None:
"""Return the supported extension for an upload, or None if unsupported.
Matches on the MIME type first, then falls back to the filename extension
(browsers often send .md and .csv as application/octet-stream).
"""
mime = (content_type or "").split(";")[0].strip().lower()
if mime in _MIME_TO_EXT:
return _MIME_TO_EXT[mime]
ext = os.path.splitext(filename or "")[1].lstrip(".").lower()
if ext == "markdown":
ext = "md"
return ext if ext in FILE_TYPES else None
def extract_text(data: bytes, ext: str) -> str:
"""Extract plain text from file bytes. Text files decode as UTF-8 with replacement."""
if ext == "pdf":
try:
with fitz.open(stream=data, filetype="pdf") as doc:
return "\n".join(page.get_text() for page in doc).strip()
except Exception as e:
raise FileExtractionError(f"Could not read PDF: {e}") from e
if ext in FILE_TYPES:
return data.decode("utf-8", errors="replace").lstrip("")
raise FileExtractionError(f"Unsupported file type: {ext}")
def cap_text(text: str, limit: int = MAX_ATTACHMENT_CHARS) -> str:
if len(text) <= limit:
return text
return f"{text[:limit]}\n{TRUNCATION_MARKER}"
def attachment_block(name: str, text: str) -> str:
"""Wrap (capped) file text for the user turn sent to the agent."""
safe_name = name.replace('"', "'")
return f'<attached file "{safe_name}">\n{cap_text(text)}\n</attached>'
+25
View File
@@ -61,3 +61,28 @@ async def delete_image(key: str) -> None:
async with _get_client() as client:
await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)
logging.info(f"Deleted image from S3: {key}")
async def upload_file(
file_bytes: bytes, key: str, content_type: str, metadata: dict[str, str]
) -> str:
"""Store a non-image object. Metadata values must be ASCII (S3 headers)."""
async with _get_client() as client:
await client.put_object(
Bucket=S3_BUCKET_NAME,
Key=key,
Body=file_bytes,
ContentType=content_type,
Metadata=metadata,
)
logging.info(f"Uploaded file to S3: {key}")
return key
async def get_file(key: str) -> tuple[bytes, dict[str, str]]:
"""Return an object's bytes and its user metadata."""
async with _get_client() as client:
response = await client.get_object(Bucket=S3_BUCKET_NAME, Key=key)
body = await response["Body"].read()
metadata = response.get("Metadata", {}) or {}
return body, metadata