Session Storage
The agent-server is stateless - no conversation data in memory between requests. Every piece of session state is persisted to the Pimcore database so conversations survive restarts, crashes, and container redeploys, and can be listed and resumed across browser sessions. (The Copilot runtime the SDK spawns keeps a second, separate copy of the conversation on disk - see Copilot runtime session state - but PHP remains the record for the chat UI.)
What is stored
| Kind | Contents |
|---|---|
| Session metadata | Session ID, user ID, current agent name, title, creation timestamp, last-activity timestamp. |
| Messages | Full conversation history with structured parts (text, thinking, debug, widgets, errors) and a status (pending / streaming / complete / error). |
| Proposal statuses | Resolution state of HITL proposals (pending, applied, rejected, error). |
PHP is the record of truth for chat. The live Mercure layer is lossy by design; a reopened, reconnected, or overnight session is reconstructed by reading PHP (see Real-time Sync). For that to work the in-progress assistant turn is persisted incrementally, not only at completion.
Where it lives
The agent-server calls the PHP Studio Backend API over HTTP. PHP writes to three tables, all created automatically by the bundle installer:
| Table | Content |
|---|---|
bundle_agent_sessions | Session metadata. |
bundle_agent_messages | Message history (JSON-encoded parts). |
bundle_agent_proposal_statuses | Proposal statuses and payloads. |
Sessions survive container restarts and are available across every agent-server instance that points at the same Pimcore database.
Copilot runtime session state
PHP is the record for the chat UI, but it is not what the model resumes against. The Copilot runtime the SDK spawns maintains its own copy of the conversation on disk - the model's working context: user/assistant/tool messages (including full tool results), reasoning, and compaction checkpoints. Per session it writes:
| File | Content |
|---|---|
events.jsonl | Append-on-event conversation log (the replayable transcript). |
session.db | SQLite index of the session. |
checkpoints/ | Compaction snapshots (see Interactive Input → pauseAfter and the infinite-session compaction behaviour). |
client.resumeSession(id) reloads this store to restore the model's in-context conversation - e.g. after an
agent-server process restart, or when a session is evicted from the adapter's in-memory cache. PHP's
priorConversationContext is only a lossy text fallback (recent messages, no tool results) injected on a
destroy+recreate (agent switch, token re-mint, MCP transport reset); it is not a substitute for the runtime store.
Location and durability
The storage root is the client-level baseDirectory (set in CopilotAdapter.initClient), which sets COPILOT_HOME
on the spawned runtime. It is sourced from AGENT_SERVER_COPILOT_STATE_DIR (default /app/.copilot-state), bind-mounted
from ./var/tmp/copilot-state in Compose.
This must be a durable mount. When
baseDirectoryis unset the runtime defaults to~/.copilotinside the container's ephemeral writable layer, which is wiped on container recreation (down/up, rebuild, image bump) - the SDK then resumes an empty conversation and the agent appears to "start over". Atsx watchprocess restart alone keeps the container filesystem, so it does not lose history. To wipe runtime state deliberately, see Development (rm -rf var/tmp/copilot-state/*).
Lifecycle
1. Create POST /agent-server/api/chat (new session)
→ store.createSession(meta) → sessionId returned
2. User msg The user message is persisted before the turn starts
→ store.appendMessage(sessionId, userMessage)
3. Placeholder At turn start the assistant message is created with its stable id
→ store.updateMessage(sessionId, messageId, [], 'streaming')
4. Stream The turn-so-far is flushed incrementally (coalesced)
→ store.updateMessage(sessionId, messageId, parts, 'streaming')
5. Finalize onComplete advances the status and writes the final parts
→ store.updateMessage(sessionId, messageId, parts, 'complete')
6. Title Generated after first exchange (see Request Lifecycle)
→ store.updateSession(sessionId, {title})
7. Resume User returns to / reconnects an existing session
→ store.getSession(sessionId)
→ store.getMessages(sessionId) ← includes a still-streaming message
→ store.getProposalStatuses(sessionId)
8. Delete User deletes conversation
→ store.deleteSession(sessionId)
→ Cascades: messages, proposals, uploaded files
Timing details - in particular the onComplete callback that finalizes the assistant message and triggers title
generation - live in Request Lifecycle.
Incremental persistence (the record)
The agent-server's php-flush-sink writes the in-progress assistant message to PHP throughout a turn, so a
reopened/overnight session reflects the turn-so-far rather than appearing only at completion:
- Placeholder at turn start. The assistant message is upserted with its stable id and
status: streamingbefore any content lands, so other tabs / a reload see the in-progress bubble keyed consistently. - Coalesced flushes.
text-deltaevents debounce a flush (~1.5 s); any non-delta event (tool call, widget, error, notice) is a semantic boundary and flushes immediately. At most one flush is in flight (drop-coalesce, newest wins). streaming→complete.onCompletewaits for any in-flight streaming flush to settle, then writes the terminalcompletestate so a finished turn never gets stuckstreamingon reload.debugandthinkingparts are persisted, so the Activity and reasoning bubbles survive a reload. The agent-server's response schema (session.schema.ts) carries adebugpart variant - without it the catch-upGET /sessions/:idserializer rejects any persisted message that contains one.system-noticepersists as a separatesystem-role message (not a part of the assistant message), so the transport-recovery narrative survives a reload - important for overnight turns, where transport resets are most likely to fire.
The PATCH endpoint
store.updateMessage calls a dedicated PHP route:
PATCH /pimcore-studio/api/bundle/agent/sessions/{id}/messages/{messageId}
body: { parts, status }
UpdateMessageController → AgentSessionService::updateMessage upserts by message id (create-on-first-flush,
overwrite-in-place thereafter), ownership-checked against the session's user. parts stays opaque JSON - every part
the agent-server sends (text, widget, debug, thinking, …) is persisted verbatim, so a reopened session
reproduces the full live turn including the Activity and reasoning bubbles.
Configuration
The session store is not a configurable backend - the agent-server always calls the PHP Studio Backend API.
The only wiring knob is PIMCORE_INTERNAL_URL (default http://nginx), which tells the agent-server where
to reach Pimcore. See Configuration → Environment Variables.