Conversation history (SQLite)
Storage
db.rs opens a SQLite database at <config-dir>/ai-leash/history.db
(the same dirs::config_dir().join("ai-leash") convention
context.rs uses for the global AGENTS.md/memory — see
context-and-memory.md), created on first run
via AppState’s Default impl (Db implements Default too, opening
and initializing schema eagerly). WAL journal mode is enabled for
better concurrent read/write behavior. Schema:
CREATE TABLE conversations (
id TEXT PRIMARY KEY, -- == project root path, for now
project_root TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- also the ordering key
conversation_id TEXT NOT NULL REFERENCES conversations(id),
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_calls TEXT, -- JSON-encoded Vec<ToolCall>, nullable
created_at INTEGER NOT NULL
);(See “What gets persisted, and what doesn’t” below for the third table,
sub_agents — sub-agent metadata that doesn’t fit conversations.)
No separate seq column — messages.id’s AUTOINCREMENT already
gives strict insertion order, which is all that’s needed since messages
are always appended, never reordered or edited in place.
Conversation id == project path (for now)
There’s no independent “conversation” concept yet — ChatPanel.tsx’s
sessionId (previously a fresh crypto.randomUUID() on every mount)
is now projectRoot ?? crypto.randomUUID(), so it’s stable across app
restarts as long as you reopen the same project. CenterPanel already
remounts ChatPanel on projectRoot change (key={projectRoot}), so
this only evaluates once per project per app run. This matches the
existing “one conversation per project for now” scoping used elsewhere
(store.ts’s panelStateByConversation, also keyed by project path —
see ui-shell.md); wiring multiple named conversations
per project later will need a real conversation id distinct from the
project path, at which point this doubling-up goes away.
What gets persisted, and what doesn’t
chat.rs’s push_message — the single choke point everything already
went through for updating in-memory chat_sessions — now also calls
db::save_message before it touches the HashMap. Only one thing is
silently skipped there (db::save_message no-ops rather than erroring,
since a persistence failure shouldn’t ever break the live chat):
system-role messages, rebuilt from AGENTS.md/memory on every
single turn by refresh_system_prompt (see
agent-chat.md), which mutates chat_sessions
directly and never calls push_message — so in practice a system
message can’t reach save_message at all; the explicit role check
there is just a defensive belt-and-suspenders guard.
Sub-agent sessions are persisted too, exactly like a top-level
session — there’s no exclusion by session id shape anymore
(sub_session_ids round-trip through conversations/messages same
as a project’s own id). A separate sub_agents table tracks the
metadata messages can’t express — one row per spawn_sub_agent-spawned
sub-agent:
CREATE TABLE sub_agents (
id TEXT PRIMARY KEY, -- == sub_session_id
parent_session_id TEXT NOT NULL,
description TEXT NOT NULL,
prompt TEXT NOT NULL,
status TEXT NOT NULL, -- "running" | "done" | "error"
result TEXT, -- set once finished
started_at INTEGER NOT NULL,
finished_at INTEGER -- set once finished
);db::record_sub_agent_started/record_sub_agent_finished write this
row at the start/end of execute_tool’s "spawn_sub_agent" arm and its
detached background task respectively (see
agent-chat.md); list_sub_agents_for_parent (scoped,
used by the list_sub_agents/read_sub_agent tools) and
list_all_sub_agents (cross-project, used by the list_sub_agents
Tauri command for the sidebar) read it back. Kept indefinitely — no
age-based expiry, unlike the frontend-only 24h cap this replaced.
Persisting a message requires a project to be open —
commands::get_root_path returning Err (no project open) means
push_message skips the database entirely, same reasoning as above:
there’s no stable id to key a conversation by without one.
Loading history back
load_conversation_history(session_id) (Tauri command, chat.rs) is
called once from a ChatPanel mount effect, before the
event-listener-registration effect. It checks chat_sessions first —
if the session’s already in memory it’s returned as-is; otherwise
db::load_messages reads it from disk and the result seeds
chat_sessions too, so the loop can resume the conversation on the
next send_prompt without needing to hit SQLite again this run. This
same command, unmodified, is what SubAgentChatTab.tsx calls to
(re)hydrate a specific sub-agent’s transcript from disk — its id
round-trips through chat_sessions/SQLite exactly like a top-level
session’s now, so no separate command was needed.
The frontend’s messagesToEntries() (src/lib/chatEntries.ts)
rebuilds a display Entry[] from the returned whole (non-streamed)
PersistedMessage[] — a different code path from the live
appendThinking/appendChunk/appendToolCall/applyToolResult
helpers used for streaming, since there’s no delta-by-delta replay
here, just whole messages already containing their final content:
user/assistantmessages becomeTextEntrys (timetaken from the message’s realcreatedAt, notDate.now()).- An
assistantmessage’stool_callseach become aToolEntry(noresultyet). - A
tool-role message doesn’t carry which call it answers — Ollama’s own history format doesn’t need that, since messages are always replayed back to it in order — so it’s matched positionally against the earliest still-unfilledToolEntry, the same assumptionrun_agent_loopalready relies on. thinkingdeltas are never persisted at all (there’s nothinkingrole inChatMessage), so reloaded history never shows them — same as it always looked before this feature existed, just now surviving a restart instead of only a single run.
ChatPanel.tsx’s hydration effect guards against clobbering a live
update that might have arrived while the (async) load was still in
flight, via setEntries((prev) => (prev.length === 0 ? loaded : prev))
— defensive rather than load-bearing, since nothing can actually send a
message before the component has mounted and the user has interacted
with it.
Testing
db.rs has #[cfg(test)] unit tests exercising the SQL directly
(round-tripping plain messages in order, round-tripping tool_calls
JSON, confirming only system messages are excluded — sub-agent
sessions round-trip like any other, confirming two conversations’
messages don’t leak into each other, and exercising the sub_agents
table’s start/finish lifecycle and its cross-parent scoping) against a
throwaway file in the OS temp dir per test — cargo test --lib db::.
This is the fastest way to verify a change to the schema or save/load
logic without going through Ollama or the UI at all.