# When an AI Leaves the DM, Make the Handoff Atomic

> Source: <https://dev.to/susiewang/when-an-ai-leaves-the-dm-make-the-handoff-atomic-4771>
> Published: 2026-09-11 17:11:54+00:00

Users may prefer one AI assistant over another for the same reason they prefer a particular coworker: predictable behavior builds trust.

That comparison has a dangerous limit in direct messages. A coworker knows when they have handed a conversation to someone else. An AI integration often does not. The bot keeps generating while an agent opens the thread, both responders send a message, and the user can no longer tell who is responsible.

The practical problem is not choosing the most personable model. It is controlling **reply authority**.

In this tutorial, we will build a TypeScript coordinator for a Tencent RTC social-messaging experience with these properties:

Tencent RTC's Social Messaging solution covers scenarios including 1-to-1 chat, group discussion, communities, and rich media. This tutorial focuses on the 1-to-1 DM case described in the [official Social Messaging solution](https://trtc.io/solutions/social-messaging).

The central invariant is small enough to put in a pull-request description:

At most one responder has authority to answer a DM, and changing that authority invalidates all unfinished replies from the previous owner.

This separates demonstrated AI utility from the larger promise implied by calling an AI a coworker.

A model can draft a routine answer, summarize selected context, or recommend escalation. It cannot independently guarantee that its context is current, that a human has not claimed the thread, or that a timed-out send did not actually arrive. Those are application-state problems.

We will represent ownership with the following modes:

| Mode | Who may reply? | What the user should see | 
|---|---|---|
| `consent_required` | Neither | A choice to start AI assistance or request a person | 
| `ai_ready` | AI, after a user message | AI assistance is enabled | 
| `ai_generating` | Nobody yet | A cancelable working indicator | 
| `moderating` | Nobody yet | The draft is not visible | 
| `bot_sending` | Nobody else | The approved reply is being delivered | 
| `delivery_unknown` | Nobody | Delivery is being checked; do not regenerate | 
| `handoff_pending` | Neither | A person has been requested | 
| `human_active` | The claimed agent | The agent's identity or role is visible | 
| `ended` | Neither | The conversation is closed | 

Notice that `handoff_pending` does not grant authority to the AI just because an agent is slow to arrive. Slow escalation is still escalation.

```
mkdir atomic-dm-handoff
cd atomic-dm-handoff
npm init -y
npm install --save-dev typescript tsx vitest @types/node
npx tsc --init --strict
mkdir src
npm pkg set scripts.test="vitest run"
npm pkg set scripts.demo="tsx src/demo.ts"
```

Our core will not import a chat SDK or model client. Keeping the state transition pure lets us reproduce races without waiting for a network.

Create `src/coordinator.ts`:

```
export type Mode =
  | "consent_required"
  | "ai_ready"
  | "ai_generating"
  | "moderating"
  | "bot_sending"
  | "delivery_unknown"
  | "handoff_pending"
  | "human_active"
  | "ended";

export type Draft = {
  turnId: string;
  text: string;
};

export type State = {
  conversationId: string;
  mode: Mode;
  epoch: number;
  turnId?: string;
  draft?: Draft;
  agentId?: string;
};

export type Event =
  | { type: "USER_OPTED_IN" }
  | {
      type: "USER_MESSAGE";
      messageId: string;
      requiresHuman: boolean;
    }
  | { type: "AI_DRAFTED"; epoch: number; turnId: string; text: string }
  | { type: "MODERATION_APPROVED"; epoch: number; turnId: string }
  | {
      type: "MODERATION_REJECTED";
      epoch: number;
      turnId: string;
      reason: "blocked" | "unavailable";
    }
  | { type: "BOT_DELIVERED"; epoch: number; turnId: string }
  | { type: "BOT_DELIVERY_UNKNOWN"; epoch: number; turnId: string }
  | {
      type: "DELIVERY_RECONCILED";
      epoch: number;
      turnId: string;
      delivered: boolean;
    }
  | { type: "REQUEST_HUMAN" }
  | { type: "HUMAN_CLAIMED"; agentId: string }
  | { type: "HUMAN_RELEASED" }
  | { type: "END" };

export type Effect =
  | {
      type: "GENERATE";
      epoch: number;
      turnId: string;
      sourceMessageId: string;
    }
  | { type: "MODERATE"; epoch: number; turnId: string; text: string }
  | {
      type: "SEND_BOT";
      epoch: number;
      turnId: string;
      clientMessageId: string;
      text: string;
    }
  | {
      type: "RECONCILE_DELIVERY";
      epoch: number;
      turnId: string;
      clientMessageId: string;
    }
  | { type: "NOTIFY_HUMANS"; conversationId: string }
  | { type: "SHOW_STATUS"; text: string };

export type Result = {
  state: State;
  effects: Effect[];
};

export const initialState = (conversationId: string): State => ({
  conversationId,
  mode: "consent_required",
  epoch: 0,
});

function matchesActiveTurn(
  state: State,
  event: { epoch: number; turnId: string },
): boolean {
  return state.epoch === event.epoch && state.turnId === event.turnId;
}

function requestHandoff(state: State): Result {
  const next: State = {
    conversationId: state.conversationId,
    mode: "handoff_pending",
    // Incrementing the epoch makes every unfinished callback stale.
    epoch: state.epoch + 1,
  };

  return {
    state: next,
    effects: [
      { type: "NOTIFY_HUMANS", conversationId: state.conversationId },
      { type: "SHOW_STATUS", text: "A person has been requested." },
    ],
  };
}

export function reduce(state: State, event: Event): Result {
  if (state.mode === "ended") {
    return { state, effects: [] };
  }

  if (event.type === "END") {
    return {
      state: {
        conversationId: state.conversationId,
        mode: "ended",
        epoch: state.epoch + 1,
      },
      effects: [],
    };
  }

  if (event.type === "REQUEST_HUMAN") {
    return requestHandoff(state);
  }

  if (event.type === "USER_OPTED_IN" && state.mode === "consent_required") {
    return {
      state: { ...state, mode: "ai_ready", epoch: state.epoch + 1 },
      effects: [{ type: "SHOW_STATUS", text: "AI assistance is on." }],
    };
  }

  if (event.type === "USER_MESSAGE" && state.mode === "ai_ready") {
    if (event.requiresHuman) return requestHandoff(state);

    const epoch = state.epoch + 1;
    const turnId = `turn:${event.messageId}`;

    return {
      state: { ...state, mode: "ai_generating", epoch, turnId },
      effects: [
        {
          type: "GENERATE",
          epoch,
          turnId,
          sourceMessageId: event.messageId,
        },
      ],
    };
  }

  if (
    event.type === "AI_DRAFTED" &&
    state.mode === "ai_generating" &&
    matchesActiveTurn(state, event)
  ) {
    return {
      state: {
        ...state,
        mode: "moderating",
        draft: { turnId: event.turnId, text: event.text },
      },
      effects: [
        {
          type: "MODERATE",
          epoch: event.epoch,
          turnId: event.turnId,
          text: event.text,
        },
      ],
    };
  }

  if (
    event.type === "MODERATION_APPROVED" &&
    state.mode === "moderating" &&
    state.draft &&
    matchesActiveTurn(state, event)
  ) {
    const clientMessageId = `ai:${state.conversationId}:${event.turnId}`;

    return {
      state: { ...state, mode: "bot_sending" },
      effects: [
        {
          type: "SEND_BOT",
          epoch: event.epoch,
          turnId: event.turnId,
          clientMessageId,
          text: state.draft.text,
        },
      ],
    };
  }

  if (
    event.type === "MODERATION_REJECTED" &&
    matchesActiveTurn(state, event)
  ) {
    return requestHandoff(state);
  }

  if (
    event.type === "BOT_DELIVERED" &&
    state.mode === "bot_sending" &&
    matchesActiveTurn(state, event)
  ) {
    return {
      state: {
        conversationId: state.conversationId,
        mode: "ai_ready",
        epoch: state.epoch,
      },
      effects: [],
    };
  }

  if (
    event.type === "BOT_DELIVERY_UNKNOWN" &&
    state.mode === "bot_sending" &&
    matchesActiveTurn(state, event)
  ) {
    return {
      state: { ...state, mode: "delivery_unknown" },
      effects: [
        {
          type: "RECONCILE_DELIVERY",
          epoch: event.epoch,
          turnId: event.turnId,
          clientMessageId: `ai:${state.conversationId}:${event.turnId}`,
        },
        { type: "SHOW_STATUS", text: "Checking message delivery…" },
      ],
    };
  }

  if (
    event.type === "DELIVERY_RECONCILED" &&
    state.mode === "delivery_unknown" &&
    matchesActiveTurn(state, event)
  ) {
    if (event.delivered) {
      return {
        state: {
          conversationId: state.conversationId,
          mode: "ai_ready",
          epoch: state.epoch,
        },
        effects: [],
      };
    }

    // Do not regenerate after an uncertain send. Move to a person instead.
    return requestHandoff(state);
  }

  if (event.type === "HUMAN_CLAIMED" && state.mode === "handoff_pending") {
    return {
      state: {
        conversationId: state.conversationId,
        mode: "human_active",
        epoch: state.epoch + 1,
        agentId: event.agentId,
      },
      effects: [{ type: "SHOW_STATUS", text: "A person joined the DM." }],
    };
  }

  if (event.type === "HUMAN_RELEASED" && state.mode === "human_active") {
    return {
      state: {
        conversationId: state.conversationId,
        mode: "consent_required",
        epoch: state.epoch + 1,
      },
      effects: [
        {
          type: "SHOW_STATUS",
          text: "Human assistance ended. Choose whether to use AI again.",
        },
      ],
    };
  }

  // Late, duplicate, or invalid events are deliberately ignored.
  return { state, effects: [] };
}
```

The `epoch` is the cancellation boundary. A model request can still finish after a handoff, but its callback no longer matches the conversation's active epoch and therefore cannot progress to moderation or delivery.

This is stronger than trying to cancel an HTTP request. Cancellation is an optimization; rejecting stale results is the correctness mechanism.

Create `src/coordinator.test.ts`:

``` js
import { describe, expect, it } from "vitest";
import { initialState, reduce } from "./coordinator";

function startAiTurn() {
  let state = reduce(initialState("dm-42"), {
    type: "USER_OPTED_IN",
  }).state;

  state = reduce(state, {
    type: "USER_MESSAGE",
    messageId: "msg-1",
    requiresHuman: false,
  }).state;

  return state;
}

describe("DM reply authority", () => {
  it("rejects a model result that arrives after handoff", () => {
    const generating = startAiTurn();
    const epoch = generating.epoch;
    const turnId = generating.turnId!;

    const handedOff = reduce(generating, {
      type: "REQUEST_HUMAN",
    }).state;

    const late = reduce(handedOff, {
      type: "AI_DRAFTED",
      epoch,
      turnId,
      text: "This must never be sent.",
    });

    expect(late.state.mode).toBe("handoff_pending");
    expect(late.effects).toEqual([]);
  });

  it("does not send a draft before moderation", () => {
    const generating = startAiTurn();

    const drafted = reduce(generating, {
      type: "AI_DRAFTED",
      epoch: generating.epoch,
      turnId: generating.turnId!,
      text: "Candidate response",
    });

    expect(drafted.state.mode).toBe("moderating");
    expect(drafted.effects[0]?.type).toBe("MODERATE");
    expect(drafted.effects.some((effect) => effect.type === "SEND_BOT")).toBe(false);
  });

  it("hands off when moderation is unavailable", () => {
    const generating = startAiTurn();
    const drafted = reduce(generating, {
      type: "AI_DRAFTED",
      epoch: generating.epoch,
      turnId: generating.turnId!,
      text: "Candidate response",
    }).state;

    const failed = reduce(drafted, {
      type: "MODERATION_REJECTED",
      epoch: drafted.epoch,
      turnId: drafted.turnId!,
      reason: "unavailable",
    });

    expect(failed.state.mode).toBe("handoff_pending");
    expect(failed.effects[0]?.type).toBe("NOTIFY_HUMANS");
  });

  it("requires new consent after the human leaves", () => {
    let state = startAiTurn();
    state = reduce(state, { type: "REQUEST_HUMAN" }).state;
    state = reduce(state, {
      type: "HUMAN_CLAIMED",
      agentId: "agent-7",
    }).state;
    state = reduce(state, { type: "HUMAN_RELEASED" }).state;

    expect(state.mode).toBe("consent_required");
  });

  it("reconciles an uncertain send instead of sending again", () => {
    let state = startAiTurn();
    state = reduce(state, {
      type: "AI_DRAFTED",
      epoch: state.epoch,
      turnId: state.turnId!,
      text: "Approved later",
    }).state;
    state = reduce(state, {
      type: "MODERATION_APPROVED",
      epoch: state.epoch,
      turnId: state.turnId!,
    }).state;

    const unknown = reduce(state, {
      type: "BOT_DELIVERY_UNKNOWN",
      epoch: state.epoch,
      turnId: state.turnId!,
    });

    expect(unknown.state.mode).toBe("delivery_unknown");
    expect(unknown.effects[0]?.type).toBe("RECONCILE_DELIVERY");
    expect(unknown.effects.some((effect) => effect.type === "SEND_BOT")).toBe(false);
  });
});
```

Run the suite:

```
npm test
```

These tests verify policy, not model quality. That distinction matters. A fluent answer can still be invalid because it arrived after a person took ownership.

The effect names above are application concepts, not claims about Tencent RTC SDK method names. Map them to the SDK and backend interfaces appropriate to your selected platform and documented integration.

```
type MessageAuthor = "user" | "ai" | "human" | "system";

type OutgoingMessage = {
  conversationId: string;
  clientMessageId: string;
  author: MessageAuthor;
  text: string;
  replyToMessageId?: string;
};

interface ChatPort {
  send(message: OutgoingMessage): Promise<
    | { outcome: "delivered"; serverMessageId: string }
    | { outcome: "unknown" }
    | { outcome: "failed"; retryable: boolean }
  >;

  findByClientMessageId(
    conversationId: string,
    clientMessageId: string,
  ): Promise<{ delivered: boolean }>;
}

interface ModelPort {
  draft(input: {
    conversationId: string;
    sourceMessageId: string;
    context: readonly ContextMessage[];
  }): Promise<{ text: string }>;
}

interface ModerationPort {
  review(text: string): Promise<
    | { decision: "approved" }
    | { decision: "blocked" }
    | { decision: "unavailable" }
  >;
}

type ContextMessage = {
  messageId: string;
  author: "user" | "ai" | "human";
  text: string;
  consentedForAi: boolean;
};
```

There are three important implementation details here.

Do not render every response as a generic account avatar. Store `author: "ai"` and `author: "human"` as product data, even if both are delivered into the same DM.

The interface should also show transitions such as:

These are not decorative status messages. They expose the authority state users otherwise have to guess.

The reducer prevents two claims in one local event sequence, but two agents can still click **Claim** concurrently from separate devices.

Persist a lease similar to:

```
type ReplyLease = {
  conversationId: string;
  ownerType: "ai" | "human";
  ownerId: string;
  version: number;
};
```

The backend should update the lease only if the stored `version` still matches the version read by the claimant. The losing agent receives the current owner instead of silently becoming a second responder.

Do not rely on a disabled button for this. UI state cannot serialize distributed claims.

A handoff does not imply that every historical message should be sent to a model. Build context from an explicit policy:

```
export function compileAiContext(
  messages: readonly ContextMessage[],
  maximumMessages: number,
): ContextMessage[] {
  return messages
    .filter((message) => message.consentedForAi)
    .filter((message) => message.author !== "human")
    .slice(-maximumMessages);
}
```

Excluding human-authored messages by default prevents an agent's private or operational wording from automatically becoming future model context. If your product needs those messages, make that a deliberate consent and retention decision rather than an accidental consequence of loading the transcript.

Do not ask the model to make every escalation decision. Use deterministic rules for conditions your product already understands, then let the model recommend handoff only inside the remaining gray area.

A practical ordering is:

A model recommendation should become an event for the coordinator, not an invisible transfer of control inside a prompt.

This is where the “favorite AI” framing becomes useful perspective. Preference can tell you that consistency matters. It does not justify giving a model durable authority over a conversation. Identity, consent, and ownership still belong to the application.

Multilingual DMs introduce another tempting shortcut: feeding translated text back into the assistant as though it were the original message.

Avoid that. Store the immutable source text and treat translation as a derived view with its own language and status metadata. If the user asks for an on-demand translation, display it alongside the source rather than silently replacing the source record.

Tencent RTC documents on-demand text-message translation through TUIChat, including supported content types, languages, and edition conditions. Check the current constraints in the official [TUIChat message translation documentation](https://trtc.io/document/60772) before enabling the control.

For AI context, choose one explicit policy:

Do not let a translated view quietly become new canonical evidence. Translation can alter nuance, and a human agent needs to know which text the assistant actually evaluated.

A convincing happy-path demo is not enough. Reproduce these cases while recording the state, epoch, turn ID, client message ID, and visible UI status.

Expected result:

`handoff_pending` immediately.`unavailable` from `blocked`.` delivery_unknown`.` clientMessageId` is used for reconciliation.`consent_required`.
Before connecting the workflow to production DMs, verify:

The broader lesson is that users do not merely develop a preference for an AI's prose. They develop expectations about who is listening, who is responding, and what happens when automation reaches its limit.

A reliable DM assistant earns that predictability through visible state and narrow authority—not through a more convincing personality.

**Disclosure:** I have a content relationship with Tencent RTC. I used the official Tencent RTC Social Messaging and TUIChat documentation as implementation references for this article.

Where would you revoke AI reply authority in your own messaging product: only when the user requests a person, or also after uncertainty such as moderation downtime and unknown message delivery?
