# Ticket 2.3 — Session Management Layer

**Phase**: 2 — Server & WebSocket Layer
**Priority**: Critical (blocking)
**Depends on**: 1.4, 2.2

## Description

Manage the mapping between OpenCode sessions and the relay's local representation. Handles session creation, switching, listing, deletion, renaming, search, and history replay.

**Important**: OpenCode stores sessions in SQLite (not JSONL like claude-relay). The relay should NOT duplicate session storage — OpenCode is always the source of truth. The relay proxies OpenCode's session API and maintains only in-memory state for active sessions and client tracking.

### Key API Endpoints

- `POST /session/` — create session (returns Session.Info with id, projectID, directory, title, parentID?, time)
- `GET /session/` — list sessions (supports `directory`, `start`, `search`, `limit`, `roots` query params)
- `GET /session/:id` — get session details
- `GET /session/:id/message?limit=` — get messages with parts (returns MessageV2.WithParts[])
- `DELETE /session/:id` — delete (cascades to children)
- `PATCH /session/:id` — update title, archive status
- `GET /session/:id/todo` — get current todo list for session
- `GET /session/:id/children` — get child sessions (forks)

## Acceptance Criteria

### AC1: Create new session

```gherkin
Given a project with directory "/Users/me/my-app"
When a browser sends { type: "new_session" }
Then the relay calls createSession({ directory }) on the REST client
And it subscribes to SSE events for the new session ID
And it broadcasts { type: "session_switched", id: "<newId>" } to all clients
And the new session appears in the session list
```

### AC2: Switch active session

```gherkin
Given sessions "sess1" (active) and "sess2" exist
When a browser sends { type: "switch_session", id: "sess2" }
Then the relay sets "sess2" as the active session
And it loads message history for "sess2" from OpenCode
And it broadcasts the history to all connected clients
And it broadcasts { type: "session_switched", id: "sess2" }
```

### AC3: List sessions

```gherkin
Given 5 sessions exist for the current project
When a browser connects or requests session list
Then it receives { type: "session_list", sessions: [...] }
And each session includes: id, title, updatedAt, messageCount
And sessions are sorted by most recently updated first
```

### AC4: Delete session

```gherkin
Given session "sess3" exists
When a browser sends { type: "delete_session", id: "sess3" }
Then the relay removes the session
And if "sess3" was active, it switches to the most recent remaining session
And it broadcasts an updated session list
```

### AC5: Rename session

```gherkin
Given session "sess1" with title "New Session"
When a browser sends { type: "rename_session", id: "sess1", title: "Auth Feature" }
Then the session title is updated
And the updated session list is broadcast
```

### AC6: Search across sessions

```gherkin
Given multiple sessions with various messages
When a browser sends { type: "search_sessions", query: "authentication" }
Then the relay searches message content across all sessions
And it returns matching sessions with highlighted excerpts
And results are sorted by relevance
```

### AC7: Progressive history loading

```gherkin
Given a session with 200 messages
When a browser connects
Then it receives the most recent 50 messages initially

When the browser sends { type: "load_more_history" }
Then it receives the next 50 messages (messages 51-100)
And loading respects turn boundaries (doesn't split user/assistant pairs)
```

### AC8: Session state persistence across relay restarts

```gherkin
Given the relay is restarted
When it starts up
Then it reconnects to OpenCode and re-fetches active sessions
And browsers can reconnect and resume where they left off
And no session data is lost (OpenCode is the source of truth)
```

## Testable Components

| Component | Test Type | What to Assert |
|-----------|-----------|----------------|
| `createSession(directory)` | Unit | Calls REST client, subscribes to events, broadcasts |
| `switchSession(id)` | Unit | Sets active, loads history, broadcasts |
| `listSessions()` | Unit | Returns sorted sessions with metadata |
| `deleteSession(id)` | Unit | Removes session, switches if active, broadcasts |
| `renameSession(id, title)` | Unit | Updates title, broadcasts |
| `searchSessions(query)` | Unit | Returns matches with excerpts |
| `loadMoreHistory(sessionId, offset)` | Unit | Returns next page, respects turn boundaries |
| State recovery on restart | Fixture | Relay restart preserves session continuity |
| Full session lifecycle | Fixture | Create → use → switch → rename → delete |

## API Shape

```ts
// src/lib/sessions.ts
export class SessionManager {
  constructor(options = {})             // { restClient, historyPageSize }

  async createSession(directory)
  async switchSession(id)
  async listSessions()
  async deleteSession(id)
  async renameSession(id, title)
  async searchSessions(query)
  async loadHistory(sessionId, offset)
  getActiveSessionId()
  getActiveSession()
}
```

## Reference

- Claude-relay sessions: `../claude-relay/lib/sessions.js`
- Claude-relay history loading: `../claude-relay/lib/project.js` (load_more_history handler)
- OpenCode sessions API: `GET/POST /session/`, `GET /session/:id/message`
- OpenCode is the source of truth for session data — relay is a thin proxy
- OpenCode stores sessions in SQLite — relay does NOT duplicate storage
- Session messages include Parts (text, tool, reasoning, etc.) — see ticket 1.3
- Messages endpoint returns MessageV2.WithParts[] (messages with their part arrays)
- Session search is server-side via `GET /session/?search=` query param
