Skip to main content

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:
  • nullno 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):
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 imported 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.