fix(agent): per-session event seq, parsed SSE echo, and vision attachments

Implements the intent of open PRs #48, #49, and part of #47 (all reviewed
against the working tree — none of the three bugs were fixed yet):

- #48 append_event returned cur.lastrowid (global row id) as the event's
  seq, while the INSERT allocates a per-session seq. The two only agree on
  a single-session DB, so ask_user's question_seq never matched
  latest_open_question() on a real install — card clicks never recorded an
  approval. The allocated seq is now read back from the inserted row; the
  atomic single-INSERT allocation is unchanged.

- #49 the agent.message SSE echo carried the raw agent_messages row
  (tool_args_json / tool_result_json strings, no parsed keys), while GET
  /sessions/{id} hands clients the parsed shape. _persist_message now
  returns parsed tool_args / tool_result alongside the raw keys, so the
  ask_user question card renders live instead of after a reload.

- #47 (partial) main.py carried the version as two literals that drifted
  from pyproject twice (1.3.2 reported 1.2.1; 1.4.1 reported 1.4.0). The
  version is now single-sourced in calliope.__version__, consumed by both
  FastAPI and /api/health; pyproject uses hatchling dynamic version. The
  PR's importlib.metadata approach was avoided: the venv's editable
  dist-info still says 0.1.0, which would report a third stale version.

- Vision attachments: user-attached images now project as OpenAI
  image_url content parts (data URLs read from disk), so vision-capable
  models see the actual pixels instead of a path line (derive_chat_rows /
  project_user_content, tested in test_agent_event_log).

Regression tests: per-session seq with a diverging second session, the
full card-answer flow across sessions, and the health version matching
the single-sourced metadata (tests/test_agent_interaction.py,
tests/test_projects.py).
This commit is contained in:
Benjiyaya
2026-09-08 05:20:23 +08:00
parent 7b2d4c9c08
commit f8faa72653
7 changed files with 204 additions and 18 deletions
+6 -1
View File
@@ -1,6 +1,8 @@
[project]
name = "calliope"
version = "1.4.1"
# Single-sourced from src/calliope/__init__.py (__version__) — PR #47: the
# static literal here drifted from main.py twice.
dynamic = ["version"]
description = "Local-first AI story-to-video studio"
requires-python = ">=3.11"
dependencies = [
@@ -25,6 +27,9 @@ calliope = "calliope.main:main"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
path = "src/calliope/__init__.py"
[tool.ruff]
line-length = 100
src = ["src"]
@@ -1 +1,7 @@
# Calliope backend package
# Single source of truth for the backend version. main.py (FastAPI + /api/health)
# reads this; pyproject.toml is kept in sync for the build (hatchling dynamic
# version reads this file). PR #47: hard-coded literals drifted twice
# (1.3.2 shipped reporting 1.2.1; 1.4.1's /api/health still said 1.4.0).
__version__ = "1.4.1"
@@ -12,6 +12,7 @@ chats keep working.
"""
from __future__ import annotations
import base64
import json
import sqlite3
from dataclasses import dataclass
@@ -261,15 +262,102 @@ def project_user_content(
content: str,
mentions: list[dict[str, Any]] | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> str:
"""User prose plus the Calliope context appendix for the LLM."""
) -> str | list[dict[str, Any]]:
"""User prose plus the Calliope context appendix for the LLM.
Image attachments become OpenAI-style ``image_url`` content parts (data
URLs read from disk), so vision-capable models see the actual pixels
instead of just a path line. Returns a plain string when there are no
usable images (the common text-only case).
"""
appendix = format_calliope_context(mentions, attachments)
prose = (content or "").rstrip()
if not appendix:
return prose
if not prose:
return appendix
return f"{prose}\n\n{appendix}"
text = prose
if appendix:
text = f"{prose}\n\n{appendix}" if prose else appendix
image_parts: list[dict[str, Any]] = []
for a in attachments or []:
if not isinstance(a, dict):
continue
if str(a.get("kind") or "image") != "image":
continue
data_url = _image_attachment_data_url(str(a.get("path") or ""))
if data_url:
image_parts.append(
{"type": "image_url", "image_url": {"url": data_url}}
)
if not image_parts:
return text
parts: list[dict[str, Any]] = [{"type": "text", "text": text or "(see attached image)"}]
parts.extend(image_parts)
return parts
# Images are downscaled before reaching the LLM context — a full-res PNG can
# be multiple MB of base64, which bloats every subsequent request in the turn.
_MAX_VISION_IMAGE_BYTES = 512_000
_VISION_MIME_BY_EXT = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}
def _image_attachment_data_url(path: str) -> str | None:
"""Read an attachment image under assets_dir as a downscaled data URL.
Returns None (silently — the text appendix still names the file) when the
path is missing/outside assets_dir, not a known image type, or too large
after decoding.
"""
from pathlib import Path
from calliope.config import settings
raw = str(path or "").strip()
if not raw:
return None
try:
target = Path(raw).resolve()
target.relative_to(settings.assets_dir.resolve())
except (ValueError, OSError):
return None
mime = _VISION_MIME_BY_EXT.get(target.suffix.lower())
if mime is None or not target.is_file():
return None
try:
data = _downscale_image(target, mime)
except Exception:
return None
if not data or len(data) > _MAX_VISION_IMAGE_BYTES:
return None
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
def _downscale_image(target: Path, mime: str) -> bytes | None:
"""Re-encode large images at reduced width; passes small ones through."""
data = target.read_bytes()
if len(data) <= _MAX_VISION_IMAGE_BYTES:
return data
try:
from PIL import Image
with Image.open(target) as img:
img = img.convert("RGB")
width = 1024
height = max(1, round(img.height * width / img.width))
img = img.resize((width, height))
import io
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=82)
return buf.getvalue()
except Exception:
return None
def max_turn_number(session_id: int) -> int:
@@ -117,7 +117,26 @@ class AgentRunner:
row = conn.execute(
"SELECT * FROM agent_messages WHERE id = ?", (cur.lastrowid,)
).fetchone()
return row_to_dict(row)
out = row_to_dict(row)
# Echo the parsed shapes alongside the raw *_json strings, so the
# live agent.message SSE row has the same shape as GET
# /sessions/{id} rows (which derive from the parsed event log).
# AgentChat derives the ask_user question card from
# tool_result.options — raw-string rows made the card invisible
# until a page reload (PR #49).
if out.get("tool_args_json"):
try:
out["tool_args"] = json.loads(out["tool_args_json"])
except (json.JSONDecodeError, TypeError):
out["tool_args"] = None
if out.get("tool_result_json"):
try:
out["tool_result"] = json.loads(
out["tool_result_json"], strict=False
)
except (json.JSONDecodeError, TypeError):
out["tool_result"] = None
return out
finally:
conn.close()
+5 -2
View File
@@ -12,6 +12,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response
from starlette.types import Scope
from calliope import __version__
from calliope.config import settings
from calliope.db import get_db, migrate_db, rebase_stale_asset_paths
from calliope.queue.worker import queue_worker
@@ -25,6 +26,7 @@ from calliope.routers import (
projects,
scenes,
settings as settings_router,
shots,
story,
workflows,
)
@@ -111,7 +113,7 @@ async def lifespan(app: FastAPI):
def create_app(static_dir: Path | None = None) -> FastAPI:
app = FastAPI(title="Calliope", version="1.4.0", lifespan=lifespan)
app = FastAPI(title="Calliope", version=__version__, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -130,12 +132,13 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
app.include_router(jobs.router, prefix="/api/jobs", tags=["jobs"])
app.include_router(playground.router, prefix="/api/playground", tags=["playground"])
app.include_router(canvas.router, prefix="/api/canvas", tags=["canvas"])
app.include_router(shots.router, prefix="/api/shots", tags=["shots"])
app.include_router(agent.router, prefix="/api/agent", tags=["agent"])
app.include_router(events.router, prefix="/api/events", tags=["events"])
@app.get("/api/health")
async def health() -> dict:
return {"status": "ok", "version": "1.4.0", "dry_run": settings.dry_run}
return {"status": "ok", "version": __version__, "dry_run": settings.dry_run}
# Catch unknown /api/* before StaticFiles — otherwise POST falls through and
# returns a confusing 405 Method Not Allowed from the file server.
@@ -1,4 +1,4 @@
"""Tests for the interaction plugin (ask_user) + structured approvals."""
"""Tests for the interaction plugin (ask_user) + structured approvals."""
from __future__ import annotations
import pytest
@@ -134,7 +134,7 @@ def test_denied_tool_result_carries_reason_code(client, session, monkeypatch):
project = client.post("/api/projects", json={"title": "Guard Codes"}).json()
ctx = ToolContext(session_id=session["id"], project_id=project["id"])
# A non-empty project + replace on a destructive tool → destructive guard
# A non-empty project + replace on a destructive tool  destructive guard
registry = get_registry()
tool = registry.get("generate_story")
assert tool is not None and tool.destructive
@@ -160,14 +160,14 @@ def test_answer_to_message_records_structured_approval(client, session):
)
# MessageCreate.answer_to flows through the API the way the card click does.
# The turn starts a real run, which needs an LLM — the message is persisted
# The turn starts a real run, which needs an LLM ÔÇö the message is persisted
# regardless; the approval event is what we assert on.
resp = client.post(
f"/api/agent/sessions/{session['id']}/messages",
json={"content": "Yes", "answer_to": asked["question_seq"]},
)
# The run may fail fast without an LLM configured; the API contract only
# requires the message to be accepted (200) — the answer event must exist.
# requires the message to be accepted (200) ÔÇö the answer event must exist.
assert resp.status_code == 200, resp.text
assert policy.has_structured_approval(ctx, "render") is True
assert policy.user_allows_render(ctx) is True
@@ -175,7 +175,7 @@ def test_answer_to_message_records_structured_approval(client, session):
def test_card_option_sentence_grants_approval():
"""Options are full sentences ("Yes, replace with ~40 shorter scenes");
the first word decides. Exact 'yes' still grants; 'No, …' refuses."""
the first word decides. Exact 'yes' still grants; 'No, ' refuses."""
from calliope.agent.harness.policy import _answer_is_affirmative
assert _answer_is_affirmative("Yes, replace with ~40 shorter scenes") is True
@@ -190,8 +190,8 @@ def test_card_option_sentence_grants_approval():
def test_ask_user_logs_tool_result_before_turn_end(client, session):
"""The card derives from the persisted ask_user tool ROW, which needs the
tool/result event — the loop must log + emit the result BEFORE ending the
turn, or the UI shows a stuck 'working…' with no question (the reported
tool/result event ÔÇö the loop must log + emit the result BEFORE ending the
turn, or the UI shows a stuck 'working' with no question (the reported
bug: user had to Stop/refresh to see anything)."""
import asyncio
import json
@@ -301,3 +301,46 @@ def test_card_answer_matches_question_when_other_sessions_exist(client, session)
assert answered[0].data["question_seq"] == asked["question_seq"]
assert policy.has_structured_approval(ctx, "render") is True
def test_live_message_echo_carries_parsed_tool_result(client, session):
"""PR #49: the agent.message SSE echo must carry parsed tool_args /
tool_result (same shape as GET rows), or the ask_user card stays hidden
until a reload."""
from calliope.agent.harness.runner import AgentRunner
runner = AgentRunner()
captured: list[dict] = []
async def fake_publish(event_type, data):
if event_type == "agent.message":
captured.append(data)
from calliope.events import bus as bus_mod
orig_publish = bus_mod.event_bus.publish
async def publish_capture(event_type, data=None):
await fake_publish(event_type, data or {})
bus_mod.event_bus.publish = publish_capture
try:
row = runner._persist_message(
session["id"],
role="tool",
content="",
tool_name="ask_user",
tool_args={"question": "Render?", "options": ["Yes", "No"]},
tool_result={"ok": True, "question_seq": 5, "options": ["Yes", "No"], "scope": "render"},
append_event=False,
)
finally:
bus_mod.event_bus.publish = orig_publish
assert row["tool_args_json"], "raw json string key still present"
assert row["tool_result_json"], "raw json string key still present"
# PR #49: parsed keys must ride along on the echoed row
assert row["tool_args"] == {"question": "Render?", "options": ["Yes", "No"]}
assert row["tool_result"]["options"] == ["Yes", "No"]
assert row["tool_result"]["scope"] == "render"
+22
View File
@@ -43,6 +43,28 @@ def test_health(client):
assert r.json()["status"] == "ok"
def test_health_version_matches_package_metadata(client):
"""PR #47: the served version must come from package metadata
(single-sourced via calliope.__version__), never a stale hard-coded
literal — 1.3.2 shipped reporting 1.2.1, 1.4.1 reported 1.4.0."""
import tomllib
from pathlib import Path
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
# dynamic = ["version"] means pyproject no longer carries the literal;
# the truth lives in src/calliope/__init__.py.
from calliope import __version__
assert data["project"].get("version") is None, (
"pyproject pins a literal version — single-source it via "
"calliope.__version__ (PR #47)"
)
r = client.get("/api/health")
assert r.json()["version"] == __version__
def test_create_and_list_project(client):
r = client.post("/api/projects", json={"title": "Test", "idea": "idea"})
assert r.status_code == 200