# Session Turns & Events

The **chat turn** is the first-class verb for "post a user message and run
the session's workflow on it", paired with a session-keyed event contract a
frontend can subscribe to. Together they replace the polling / reflection
patterns built around the deprecated `sendMessage()`.

!!! info "Stability"
    The turn endpoint, the event payloads, and the broadcast shapes below
    are **stable** in 1.x and evolve **additive-only**: fields are never
    removed or renamed; new fields may appear. Pin your consumers to the
    keys you use, not the full payload shape. See the
    [BC Policy](bc-policy.md).

## The turn model

A **turn** is the user-message-initiated unit of work:

- One turn = one user message = one `started` + one `completed` event.
- The **turn id is the user message id** — there is no separate turn
  entity.
- A turn ends at *any* pipeline stop; the status says why (see table
  below). In particular, when the workflow asks the user something
  (human-in-the-loop), that input request **is** the assistant's reply for
  this turn — the turn ends with `awaiting_input`.
- Turns are serialized per session: a second turn while one is running is
  refused with **409 Conflict**. A session in `awaiting_input` accepts the
  next turn.

| Turn status | Meaning |
|---|---|
| `queued` | Accepted; handed to the queue worker (async sessions). HTTP-only — never appears in events. |
| `running` | Accepted; executes after the HTTP response (deferred-sync sessions). HTTP-only — never appears in events. |
| `completed` | The workflow ran to the end; `assistantMessageIds` carries the write-backs. |
| `awaiting_input` | The workflow asked the user something. Answer via the interrupt API; the answer resumes the same pipeline. |
| `paused` | The pipeline stopped on a budget/signal pause; `pausedReason` says which. Resume via Pause-signal resolution — not chat input. |
| `failed` | Execution failed; the session stream carries the error bubble. |

## Executing a turn

```
POST /api/flowdrop/session/{sessionId}/turn
```

Body:

```json
{
  "content": "What's the weather in Hamburg?",
  "inputs": { "units": "metric" }
}
```

`inputs` is optional — extra named workflow inputs resolved through the
workflow's declared input ports, merged with the message.

Response — **202 Accepted**:

```json
{
  "success": true,
  "data": {
    "sessionId": "12",
    "userMessageId": "345",
    "pipelineId": null,
    "status": "running",
    "assistantMessageIds": []
  }
}
```

`userMessageId` doubles as the **turn id** in the events below.
`pipelineId` is `null` at this point — the pipeline entity is created
after the HTTP response; the `turn_started` event carries it.

Errors:

| Status | Meaning |
|---|---|
| 400 | Malformed JSON, missing `content`, or non-object `inputs`. |
| 404 | Session does not exist. |
| 403 | Missing `execute session workflow` permission or no view access to the session. |
| 409 | A turn is already running on this session. Wait for its `turn_completed` event, then post again. |
| 422 | The session has no associated workflow. |

Authentication, CSRF, and the response envelope follow the
[REST API conventions](rest-api.md). Execution is a distinct capability
(`execute session workflow`) on top of view access to the session.

## Drupal events (PHP subscribers)

Server-side consumers subscribe to two events in
`Drupal\flowdrop_session\Event`:

### `SessionTurnStartedEvent`

Name: `flowdrop_session.turn.started`. Fired once the turn's pipeline
entity exists.

| Property | Type | Meaning |
|---|---|---|
| `sessionId` | string | The session the turn runs on. |
| `turnId` | string | The user message id that initiated the turn. |
| `pipelineId` | string | The pipeline executing this turn (always set). |
| `workflowId` | string | The workflow being executed. |

### `SessionTurnCompletedEvent`

Name: `flowdrop_session.turn.completed`. Fired at any pipeline stop.

| Property | Type | Meaning |
|---|---|---|
| `sessionId` | string | The session the turn ran on. |
| `turnId` | string | The user message id that initiated the turn. |
| `pipelineId` | string\|null | Null only when execution failed before a pipeline entity was created. |
| `status` | string | `completed` \| `awaiting_input` \| `paused` \| `failed`. |
| `assistantMessageIds` | string[] | Assistant write-backs, in conversation order. |
| `pausedReason` | string\|null | Set for `paused`; null otherwise. |

Both events fire for every top-level message-driven execution regardless
of entry verb (the turn endpoint, `executeTurn()` in PHP, the deprecated
`sendMessage()`, the queue worker). Nested sub-workflow invocations on a
shared session are not turns and fire nothing.

## RealTime broadcasts

A bridge forwards both events onto the single broadcast channel
(`RealTimeBroadcastEvent`, name `flowdrop_runtime.real_time_event`) so one
subscription point carries node status, pipeline status, and turn
lifecycle — all keyed:

- `type` — `'session'` (node events use `'node'`, pipeline/execution
  events `'execution'`).
- `executionId` — **the session id** (each type is keyed by its primary
  id).
- `event` — `'turn_started'` or `'turn_completed'`.
- `data` — snake_case mirror of the event payload: `turn_id`,
  `pipeline_id`, `workflow_id` (started); `turn_id`, `pipeline_id`,
  `status`, `assistant_message_ids`, `paused_reason` (completed).

The kill-the-polling recipe: post a turn, remember `userMessageId`, and
react to the `turn_completed` broadcast whose `executionId` matches your
session and `data.turn_id` matches your turn.

!!! note "Transport"
    These broadcasts are in-process Drupal events. A wire transport for
    browsers (SSE) is a separate, explicit decision point — it will ride
    this same contract when it lands. Until then, frontends poll the
    session/message endpoints and PHP/server-side consumers subscribe
    directly.

## What a turn does *not* cover

- **Resuming after `awaiting_input`** happens through the interrupt API;
  the continuation of the resumed pipeline is observable via pipeline and
  node events, not a second turn event.
- **Resuming after `paused`** is a Pause-signal resolution (see the
  pipeline docs); same observation rule.
- **Plain posts** (`SessionService::postMessage()`) append to the
  conversation without executing anything and never fire turn events.
