Skip to main content
Version: 2026.1

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

KindContents
Session metadataSession ID, user ID, current agent name, title, creation timestamp, last-activity timestamp.
MessagesFull conversation history with structured parts (text, thinking, debug, widgets, errors) and a status (pending / streaming / complete / error).
Proposal statusesResolution 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:

TableContent
bundle_agent_sessionsSession metadata.
bundle_agent_messagesMessage history (JSON-encoded parts).
bundle_agent_proposal_statusesProposal 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:

FileContent
events.jsonlAppend-on-event conversation log (the replayable transcript).
session.dbSQLite 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 baseDirectory is unset the runtime defaults to ~/.copilot inside 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". A tsx watch process 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: streaming before any content lands, so other tabs / a reload see the in-progress bubble keyed consistently.
  • Coalesced flushes. text-delta events 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).
  • streamingcomplete. onComplete waits for any in-flight streaming flush to settle, then writes the terminal complete state so a finished turn never gets stuck streaming on reload.
  • debug and thinking parts are persisted, so the Activity and reasoning bubbles survive a reload. The agent-server's response schema (session.schema.ts) carries a debug part variant - without it the catch-up GET /sessions/:id serializer rejects any persisted message that contains one.
  • system-notice persists as a separate system-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 }

UpdateMessageControllerAgentSessionService::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.