Two agent-loop bugs found in the Sep 24 loop evaluation, folded into the
1.5.7 line:
1. A linked-session turn whose user message carried an image/video/document
attachment crashed 100% of the time: derive_llm_history projects multimodal
content as an OpenAI parts list, and orchestrate() read that list as the
goal and called .strip() on it (AttributeError: 'list' object has no
attribute 'strip'). New log.text_of_content() extracts the text part; the
goal read uses it, so single-loop and swarm paths both survive attachments.
2. When ask_user paused the turn mid tool-batch, the remaining calls of that
step never executed but also never received tool results, leaving an
assistant tool_calls message with dangling ids - an invalid request
sequence that strict OpenAI-compatible servers 400 on the next turn. The
pause path now synthesizes skipped tool/call + tool/result events and
tool-role messages for the un-executed ids before ending the turn.
The new multimodal-goal tests now stub the planner LLM: the attachment
appendix puts newlines in the extracted goal, so the message is never
trivial-path and the unstubbed planner made a real network call - the source
of the tests' flakiness (and the swarm-path test now attaches the document
it always claimed to).
Backend suite: 538 passed.
Three reported issues:
#66: the Video stage workflow picker was keyed by scene id, so choosing a
workflow for one clip overwrote every sibling clip. It is now keyed by clip
id, hydrates each clip's persisted choice, autosaves both
clips.workflow_id and form_workflow_id, and Generate One / Generate All
resolve the workflow per clip (a scene can mix image->video and
video->video extend across its clips).
#64: a 30-second ask could produce a 1:51 script - prompts carried no
per-scene scale and the duration rescale could only grow. Prompts now
state the total seconds and a per-scene budget, and the rescale may shrink
(floor 0.5) so over-written boards pull toward the target; content itself
is never edited.
#67: a hand-written directory path (e.g. ComfyUI's input dir) reached
ComfyUI and died as an opaque Errno 21. prepare_media_inputs now rejects
directory / missing local files before queueing with a node-naming error,
and the agent's run_workflow validates media input_values at call time.
Bare Comfy-side names pass untouched.
534 backend tests pass; svelte-check clean.
Live event logs (session 908) showed a planner-scheduled render task whose
tools were hidden by the HITL visibility rule, a story sub-agent denied the
add_scene tools the planner told it to use, deep-linked chats orphaning as
duplicate empty sessions, and new project assets missing from the canvas
until re-open. Render-intent now recognizes clip/shot/storyboard; sub-agent
payloads keep approval-gated tools visible (permission stays at the
execute-time guard, whose denial teaches ask_user); the story role owns
scene structure; the canvas deep-link session param is trusted; GET
/api/canvas/{id} auto-seeds new entity cards.
Patch the app-breaking regression 1.5.2 shipped: every server render threw
`localStorage.getItem is not a function`, so the UI only appeared behind a Vite
error overlay.
Node 22+ exposes a `localStorage` global that is not a Storage when no valid
`--localstorage-file` is set, which defeats the `typeof localStorage` guard the
language layer used. The code fix landed in 3363620; this commit cuts it as a
release and adds `npm test`, which fails if the unsafe idiom reappears.
Docs: docs/changelogs/2026-09-16-ssr-storage-fix.md
`localStorage.getItem is not a function` aborted every server render, so the
whole UI loaded behind a Vite error overlay.
Node 22+ exposes a global `localStorage`, and without a usable
`--localstorage-file` it is a bare object with no Storage methods. The
`typeof localStorage === 'undefined'` guard therefore passed and the next call
threw. It only bit at module/component scope, because SvelteKit evaluates those
on the server too — the same guard is harmless inside onMount and $effect.
Probe for a callable method in a shared storage helper instead of trusting the
binding to exist, and route the SSR-executed reads through it.
The dictionary layer shipped with en/zh only, so the five other translations
were stranded outside the release while the notes described a bilingual UI.
Register them: seven languages, 1001 keys each, all typed against `Dict` so a
missing key fails the build instead of rendering the raw key.
Also align `__version__`, which had stayed at 1.4.1 through the 1.5.0 and 1.5.1
releases, leaving /api/health and the OpenAPI schema reporting a stale version.
The bat scripts now emit English so non-Chinese locales render them correctly.
Docs: docs/changelogs/2026-09-16-multilingual-ui.md
The gate flags any `agent.*` / `job.*` / `canvas.*` string in calliope-web as
an SSE event the backend must publish. The new dictionary layer names UI labels
in those same dotted namespaces (`'canvas.tidy': 'Tidy'`), so every label read
as a missing event and the gate went red on main.
A dotted name that resolves to a dictionary key is a label, not a contract name:
collect the keys from lib/i18n/*.ts and exempt them.
Ship the merged dictionary layer as 1.5.2. Every user-facing string now
resolves through the en/zh dictionaries instead of hardcoded English, with
a language switcher in the header and local setup/start scripts for the
first-run path.
Docs: docs/changelogs/2026-09-16-bilingual-ui.md
1.5 moved rendering to clips but the Video stage only surfaced the scene
script, so the workflow prompt field gave no hint of what an individual
clip performs. Each clip now carries its description, shot size, and
covered dialog lines into a brief panel above the composer, with a copy
button feeding the prompt. The stage splits into player + a scrollable
input inspector so the input stack can no longer squeeze the player out.
Build Scene (/build-scene): three.js + mannequin-js blockout viewport ported
from open-media, with per-session compositions (origin='scene' keeps rails
separate), agent shot_* tools as the only toolset for that surface, viewport
toolbar, keyframe motion + camera timeline with 60s video export, and
captures usable as project reference images.
Scene to Clips: clips table (1 scene : N renderable shots), coverage agent
breaks scenes into shots with full dialogue/action allocation, script
generation stays faithful to the screenplay (verbatim dialogue, content-
derived durations, chunked + durable inserts), video pipeline re-scoped to
clips (enqueue, continue-from, export, drafts), Script/Video UI clip-first
(#N.M labels, per-scene Break Into Shots scoped to one scene).
Agent loop: lean ReAct discipline with fail-streak escalation and budget
nudge; policy treats explicit user commands as permission; Build Scene tool
scope enforced mechanically; document + video attachments feed the VLM.
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).
The loop raised the end-turn control flow BEFORE logging/emitting the
ask_user tool result, so the frontend never received the question — the
chat sat on "working…" with no card until the user hit Stop or refreshed
(the report: "the agent loop just stops there"). The result is now
logged + emitted first, then the turn ends with status awaiting_input.
- Question card renders the question text plus stacked option buttons
(long sentences read cleanly) and a hint line.
- Card answers are graded by first word: "Yes, replace with ~40 shorter
scenes" grants, "No, keep existing scenes" refuses — previously a
sentence option had to match "yes" exactly.
- Two regression tests: tool/result precedes turn/end with the question
payload; option-sentence approval matrix.
Long-form generation asked the LLM for the whole board in one response —
a 50-beat story or 20-scene script is a multi-thousand-token JSON answer
that small/local models take minutes to stream, often under-deliver, and
then a single short chunk retried the ENTIRE thing. That is the "Draft
Storyline / Regenerate Script hangs" bug.
Both generators now write incrementally:
- Script: SCRIPT_CHUNK (4) scenes per call, each fed the full context plus
the last two written scenes for continuity.
- Story: STORY_CHUNK (12) beats per call — a brief call (title, logline,
cast, opening beats) then continuations given the established cast and
recent beats.
Both renumber order_index gaplessly, retry only a short chunk, publish
agent.thinking progress per chunk, and keep single-call behavior for small
boards. The generate_story/generate_script agent tools route through the
same code, so the agent loop benefits too.
With an unreachable LLM endpoint the Review-prompt modal fired duplicate
long-hanging requests and left Generate impossible. Preview resolves now
fire exactly once per scene, the H3 rewrite times out after 30s and falls
back to the deterministic template, and on failure the modal populates the
editor with raw scene text so the user can edit + Generate or retry.
The old 2.20.x range carried high-severity npm audit advisories
(CSRF, dev-mode 404 XSS, handle-hook DoS), which pushed users toward
npm audit fix --force — a command that downgrades kit to a 2021
pre-release and breaks the install (issue #38). Bumping within the
major plus the safe audit fixes leaves only 3 low-severity transitives,
all requiring major downgrades. Recovery steps for affected users are
in the changelog.
Issues #27/#33/#28. Generate now resolves the prompt first (saved draft
or H3 rewrite) and opens an editable review modal before enqueueing;
job history drawer shows each render's exact payload with copy-to-form.
ComfyUI 400s surface error.type + per-node messages; audio uploads go
flat and VHS audio: widgets are patched. Per-scene video settings
persist via scenes.video_settings_json (additive migration) and batch
generate honors them. Version bumped to 1.3.0.
The native select was too cramped for a project with many clips. Replace
it with a wide modal (Auto / Upload file / clip grid with thumbnails).
Clip options now cover any scene in the timeline, not just earlier ones,
and uploads wire through a hidden input. Also fix the Choose-Video modal
rendering blank under Clips/Uploads tabs: duplicate asset paths crashed
Svelte's keyed each mid-render — dedupe by path and clamp the active tab
when a tile's kind changes.
The packaged FastAPI static mount returned JSON 404 for /project/{id}. Serve the SvelteKit fallback page for client routes, and point README at source until a zip is trustworthy.