> ## Documentation Index
> Fetch the complete documentation index at: https://docs.open.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026 06 28 set conversation mode action design

# Design — "Set Conversation Mode" workflow action (Autopilot ↔ Assist)

**Date:** 2026-06-28
**Status:** Approved design, pending implementation plan

## Goal

A new agentic-workflow action node that flips a **single session** between
**Autopilot** (AI auto-sends public replies) and **Assist** (AI posts an internal
draft/note for a human, resolve + handoff tools disabled), or back to the
org/channel **default**. This lets a workflow promote a session to autonomy
(e.g. "low-risk intent detected → Autopilot") or pull it back to human review
(e.g. "refund > \$500 mentioned → Assist") mid-conversation.

## Background — what "Autopilot vs Assist" is, and why this is net-new

"Autopilot vs Assist" is the **public-reply-vs-internal-draft** lever. It is
distinct from the **AI-active-vs-handed-off** lever (`assignee_id` +
`ai_closure_type`), which already has workflow actions (`assign-ticket-to-ai`,
`handoff`). This design is strictly about the former.

Today the mode lives in `chat_sessions.is_assist_mode` (`Generated<boolean>`,
`backend/src/db/opencx.ts:1519`). It is **snapshotted onto each session at
creation** from the channel's `assist_mode_enabled` setting (+ topic overrides),
and **there is no code path that writes it after creation**. So flipping a live
session's mode is genuinely new behavior.

### The read-site inconsistency (the design driver)

`is_assist_mode` is read inconsistently across channels:

* **Already session-authoritative** (read `session.is_assist_mode` directly):
  * Zendesk v2 — `backend/src/zendesk-integration-v2/zendesk.service.ts:2040,2309,2479,2772`
  * Salesforce — `backend/src/salesforce-case-integration/salesforce-case.service.ts:424,507`
  * Freshdesk — `backend/src/freshdesk-integration/freshdesk-ai-fields.service.ts:478`
  * HubSpot — via `backend/src/chat/chat.service.ts:1251`
* **Recompute from channel settings, ignoring the session flag:**
  * Native web/widget + core agent run — `backend/src/chat/chat.service.ts:1248-1252`
    (the session flag is gated behind a HubSpot-only branch)
  * Intercom reply decision — `backend/src/intercom-integration/intercom.service.ts:1668`

A naive "write `is_assist_mode`" would therefore **silently no-op on the native
web path and Intercom**. The chosen mechanism (below) fixes that without changing
behavior for any session the action never touches.

## Chosen approach — nullable override column (Option B)

Add a new nullable column:

```
chat_sessions.is_assist_mode_override  boolean  NULL  DEFAULT NULL
```

Semantics:

* `null` → **no override**; resolution falls back to exactly today's logic
  (channel recompute on web/Intercom, snapshot on integrations). Every existing
  session is `null`, so **zero behavior change** for anything the action doesn't
  touch — satisfies the "no casual change to existing behavior" rule.
* `true` → force **Assist** for this session, authoritative on every channel.
* `false` → force **Autopilot** for this session, authoritative on every channel.

The override is coalesced **in front of** whatever each read site computes today,
inline at each call site (per the "no new shared helpers" rule):

```ts theme={"dark"}
const effectiveAssistMode = session.is_assist_mode_override ?? <existing expression>;
```

### Read sites to thread (`override ?? existing`)

1. `backend/src/chat/chat.service.ts:1248-1252` — primary; covers native web +
   core agent run + HubSpot. Becomes:
   `session.is_assist_mode_override ?? (isHubspot ? session.is_assist_mode : channelAssistModeEnabled)`.
2. `backend/src/intercom-integration/intercom.service.ts:1668` — wrap the
   recomputed `assistModeEnabled`.
3. Zendesk v2 — `zendesk.service.ts:2040,2309,2479,2772` (sites already reading
   `session.is_assist_mode`): wrap as `is_assist_mode_override ?? is_assist_mode`.
4. Salesforce — `salesforce-case.service.ts:507` (and `:424`).
5. Freshdesk — `freshdesk-ai-fields.service.ts:478`.

Every session object that reaches these sites must also **select the new column**
(e.g. `backend/src/chat-session/chat-session.repo.ts:183` and the per-integration
selects at `zendesk.service.ts:1066,2466`, `freshdesk-ai-fields.service.ts:259`,
etc.). The implementation plan enumerates the exact selects.

> Rejected alternatives:
> **A (reuse `is_assist_mode`)** — smaller diff, no migration, but changes the web
> path from live channel-recompute to session-sticky, a behavior change to
> existing sessions.
> **C (scope to channels that already read the flag)** — tiniest diff, but a
> silently partial feature (no-op on web + Intercom).

## The action

* **Enum:** `WorkflowActionEnum.SET_CONVERSATION_MODE` (`set-conversation-mode`),
  added to `backend/src/workflow/enums/workflow-action.enum.ts` under the
  session/ticket-state group, registered in
  `backend/src/workflow/definitions/actions/index.ts`.
* **File:** `backend/src/workflow/definitions/actions/set-conversation-mode.action.ts`,
  modeled on `tag-ticket.action.ts` (factory `createActionDefinition`, services
  lazily `await import`ed inside `run`, scoped by `ctx.organization.id`).
* **Input** `Field.Object`:
  * `ticketNumber: Field.Number` — refable, defaults to the trigger's ticket.
  * `mode: Field.Select(['autopilot', 'assist', 'follow_default'])` — refable.
* **Output** `Field.Object`: `{ success: boolean, previousMode, newMode }`.
* **Run body:**
  1. Resolve session: `ChatSessionRepo.getByStringifiedTicketNumberOrThrow({ orgId, ticketNumber })`;
     `actionRunError.runtimeError` if not found.
  2. Map mode → override value: `autopilot → false`, `assist → true`,
     `follow_default → null`.
  3. **Idempotent:** if the current `is_assist_mode_override` already equals the
     target, return `{ success: true, ... }` with **no write and no timeline note**.
  4. Write via `ChatSessionRepo.update(session.id, { is_assist_mode_override })`.
  5. Emit one **internal system timeline note** (see below).
  6. Return `{ success: true, data: { success, previousMode, newMode } }`.
* **Actor stamp:** `{ type: 'system', sub_type: 'workflow', workflow_uuid,
  workflow_serial_id, run_id }` (per `change-ticket-assignee.action.ts:91`).
* **UI:** reuses the generic field-driven config form — **no dashboard changes**.
  Run `pnpm gensdk` so the new action's metadata reaches the builder.

## Timeline note (visibility)

A new internal `chat_history` system event, modeled on `AI_RESUMED_BY_SYSTEM`
(`backend/src/chat-session/chat-session.service.ts:1780-1793`):

* Copy: e.g. *"Conversation switched to Assist mode by workflow «name»"* /
  *"…to Autopilot mode…"* / *"…reset to channel default…"*.
* **Internal-only:** hidden from the customer widget and **excluded from LLM
  input** (assert both in tests, per the extensive-defensive-tests rule).
* Pushed on the normal session-update path so the inbox reflects the change live.

## Schema change

* Migration `add_is_assist_mode_override_to_chat_sessions`:
  `ALTER TABLE chat_sessions ADD COLUMN is_assist_mode_override boolean NULL DEFAULT NULL;`
  (transactional; single statement). Then `pnpm dev:prepare` to regenerate
  `opencx.ts`.
* DTO: add `is_assist_mode_override: z.boolean().nullable()` to
  `backend/src/chat-session/dtos/chat-session.dto.ts:101`, bound via
  `satisfies z.ZodType<Selectable<DB['chat_sessions']>>` if the schema mirrors the
  row.

## Testing (mandatory closing step)

Extensive:

* Action spec (`set-conversation-mode.action.spec.ts`): input/output validation;
  all three mode values → correct override (`false`/`true`/`null`); not-found →
  `actionRunError`; **idempotent no-op** (already-target → no write, no note);
  system-actor stamp; **cross-org isolation** (another org's ticket rejected).
* Chat-service test: a session with `is_assist_mode_override = true` makes the AI
  post an **internal note** (assist) on the next run even when the web channel
  default is autopilot; `= false` posts a **public message** even when the channel
  default is assist; `= null` follows the channel default (no regression).

Defensive:

* Timeline note is internal: assert it's hidden from the widget surface and
  **excluded from LLM input**.
* Override precedence: assert `override ?? existing` at the native chokepoint —
  feed `(override=true, channel=autopilot)` and prove assist wins; feed
  `(override=null, channel=assist)` and prove the channel default still applies
  (no behavior change for untouched sessions).

## Files touched (summary)

* New: migration, `set-conversation-mode.action.ts`, action spec, chat-service spec.
* Edit: `workflow-action.enum.ts`, `actions/index.ts`, `chat-session.dto.ts`,
  `chat-session.repo.ts` (+ integration selects), the 5 read-site files above, the
  `chat_history` system-event enum/emitter, `opencx.ts` (generated).
* Run: `pnpm dev:prepare`, `pnpm gensdk`, `pnpm tsgo`, scoped tests.
