Sessions and ThreadsA session is the conversation; a run is one execution inside it. The two are distinct, and both exist.

A session is a conversation — a durable, append-only thread with a title, a status, and its own Log of events. A run is one unit of execution. These are different things, and the difference is worth stating plainly because the vocabulary shifted at the tool and UI layers while the concepts underneath did not.

Session is not a synonym for run

Both are first-class rows in the database (agents/src/sqlite-schema.sql:70 and :123), pointing at each other:

    runs.session_id — the transcript session a run drives. The type comment is explicit: "Transcript session for agent runs; workflow runs have none" (agents/protocol/src/index.ts:897). A script run has no session at all.

    sessions.run_id — the run that spawned this session, for sessions created as run children.

    sessions.parent_session_id — the parent session, for sub-sessions.

One session accumulates many runs over its life: every turn of conversation creates a new run row. RunInfo.kind is 'agent' | 'workflow', and only agent runs carry a session.

What did change is the naming at the edges. The M1 verb migration deleted the session-named toolssub_session and start_session folded into delegate, and set_session_title was removed outright (a test asserts it never returns: api-service.test.ts:2624). The UI's pinned card is a run card. So the word "session" largely vanished from what you type and see, while remaining the backbone of what you talk to.

The sessions table

agents/src/sqlite-schema.sql:70-86: id, account_id, agent_id, title, title_source, status, parent_session_id, run_id, plan_cbor, created_at, updated_at. Indexed by agent (updated_at DESC) and by parent.

Status is a derived mirror, not a source of truth. #syncSessionStatusFromRuns (agents/src/api-service.ts:3250-3269) recomputes it from run state: streaming when a live agent run references the session, error when the latest run failed, else idle. Liveness truth lives in the runs table; the column exists so older clients keep working. A subtle and deliberate detail: sessionHasLiveRun (agents/src/runs.ts:302-310) counts only queued, claimed, and running — a run parked on its children is not "streaming", because a parent waiting on background work can still hold a conversation.

Titles: the agent names its own sessions

title_source is system, user, or agent. A user-set title always wins — the update is guarded WHERE title_source <> 'user'.

Titling happens asynchronously rather than in the turn. #ensureSessionTitled (agents/src/api-service.ts:2999-3023) fires when a run starts, parks, or finalizes; if the session is still untitled and the source is system, #nameSessionWithModel (:3026) builds a digest from the first twelve events and makes one minimal, tool-less model call whose entire system prompt is: "You are a session-titling assistant. Reply with ONLY a concise one-line title (at most eight words)…" (:3095). The point, per the code's own comment, is that the agent names its sessions — the title is not an echo of the user's first words.

A wrinkle worth knowing: #setSessionTitleFromAgent (:2310) still exists but has zero call sites on this branch — a leftover of the deleted in-turn titling tool.

Replay: how a transcript becomes model input

#piMessages (agents/src/api-service.ts:4852-5065) rebuilds the provider's message list from durable events on every turn, in two passes. It does more than concatenate:

    Tool results are reattached directly behind the assistant message that called them, regardless of where the durable event actually landed, because providers require that adjacency.

    A child still running gets a synthetic result{status: 'running', note: 'Still running in the background…'} — so the model sees an honest placeholder instead of a gap.

    User actions are not replayed as tool calls. A verb the user ran through the wrench palette is rendered as tagged user text: <user_action verb="…"> and <user_action_result verb="…">, with payloads passed through escapeActionFraming() so injected content cannot close the frame early (see Security).

    The checklist is injected fresh, never stored. planStateBlock() (:411-435) renders <plan_state> from session state into each turn — see Plans.

    Window context from the client rides as <window_context> and is deliberately kept out of the durable content field.

Note that transcript compaction is not active: sessions are created with compaction: {enabled: false} (:4420). Comments elsewhere about surviving compaction describe a property the design preserves, not a running code path.

Sub-sessions

A delegated model child gets its own session, linked to the parent by parent_session_id, and its run records parent_tool_call_id — the id of the tool call that spawned it. That field is what lets a delegate row in a transcript find its child while the child is still working, before any result exists: the desktop looks up run.parentToolCallId === toolCallId across the run tree (assistant-message-rendering.tsx:1790-1812).

Session lifecycle actions in the protocol: CreateSession, ListSessions (keyset pagination on (updatedAt, id) — the id is in the cursor because a trigger batch can create several sessions in the same millisecond), GetSession, UpdateSession, DeleteSession, MessageSession, StopSession, RetrySession, and InvokeSessionTool.

RetrySession re-enters from the durable transcript without appending a new user message, and deliberately does not replay error events to the provider. It is refused if a run is live or the latest run did not fail.

Related

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime