{"slug": "when-an-ai-leaves-the-dm-make-the-handoff-atomic", "title": "When an AI Leaves the DM, Make the Handoff Atomic", "summary": "A developer has published a TypeScript tutorial for building an atomic handoff coordinator for AI-assisted direct messages on Tencent RTC's Social Messaging platform. The coordinator enforces a single invariant — at most one responder holds reply authority at a time, and any change in authority invalidates all unfinished replies from the previous owner — using a nine-mode state machine that includes consent_required, ai_generating, delivery_unknown, and human_active. The author argues that reply authority, not model personality, is the core engineering problem when an AI and a human agent share a conversation thread.", "body_md": "Users may prefer one AI assistant over another for the same reason they prefer a particular coworker: predictable behavior builds trust.\n\nThat 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.\n\nThe practical problem is not choosing the most personable model. It is controlling **reply authority**.\n\nIn this tutorial, we will build a TypeScript coordinator for a Tencent RTC social-messaging experience with these properties:\n\nTencent 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).\n\nThe central invariant is small enough to put in a pull-request description:\n\nAt most one responder has authority to answer a DM, and changing that authority invalidates all unfinished replies from the previous owner.\n\nThis separates demonstrated AI utility from the larger promise implied by calling an AI a coworker.\n\nA 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.\n\nWe will represent ownership with the following modes:\n\n| Mode | Who may reply? | What the user should see | \n|---|---|---|\n| `consent_required` | Neither | A choice to start AI assistance or request a person | \n| `ai_ready` | AI, after a user message | AI assistance is enabled | \n| `ai_generating` | Nobody yet | A cancelable working indicator | \n| `moderating` | Nobody yet | The draft is not visible | \n| `bot_sending` | Nobody else | The approved reply is being delivered | \n| `delivery_unknown` | Nobody | Delivery is being checked; do not regenerate | \n| `handoff_pending` | Neither | A person has been requested | \n| `human_active` | The claimed agent | The agent's identity or role is visible | \n| `ended` | Neither | The conversation is closed | \n\nNotice that `handoff_pending` does not grant authority to the AI just because an agent is slow to arrive. Slow escalation is still escalation.\n\n```\nmkdir atomic-dm-handoff\ncd atomic-dm-handoff\nnpm init -y\nnpm install --save-dev typescript tsx vitest @types/node\nnpx tsc --init --strict\nmkdir src\nnpm pkg set scripts.test=\"vitest run\"\nnpm pkg set scripts.demo=\"tsx src/demo.ts\"\n```\n\nOur core will not import a chat SDK or model client. Keeping the state transition pure lets us reproduce races without waiting for a network.\n\nCreate `src/coordinator.ts`:\n\n```\nexport type Mode =\n  | \"consent_required\"\n  | \"ai_ready\"\n  | \"ai_generating\"\n  | \"moderating\"\n  | \"bot_sending\"\n  | \"delivery_unknown\"\n  | \"handoff_pending\"\n  | \"human_active\"\n  | \"ended\";\n\nexport type Draft = {\n  turnId: string;\n  text: string;\n};\n\nexport type State = {\n  conversationId: string;\n  mode: Mode;\n  epoch: number;\n  turnId?: string;\n  draft?: Draft;\n  agentId?: string;\n};\n\nexport type Event =\n  | { type: \"USER_OPTED_IN\" }\n  | {\n      type: \"USER_MESSAGE\";\n      messageId: string;\n      requiresHuman: boolean;\n    }\n  | { type: \"AI_DRAFTED\"; epoch: number; turnId: string; text: string }\n  | { type: \"MODERATION_APPROVED\"; epoch: number; turnId: string }\n  | {\n      type: \"MODERATION_REJECTED\";\n      epoch: number;\n      turnId: string;\n      reason: \"blocked\" | \"unavailable\";\n    }\n  | { type: \"BOT_DELIVERED\"; epoch: number; turnId: string }\n  | { type: \"BOT_DELIVERY_UNKNOWN\"; epoch: number; turnId: string }\n  | {\n      type: \"DELIVERY_RECONCILED\";\n      epoch: number;\n      turnId: string;\n      delivered: boolean;\n    }\n  | { type: \"REQUEST_HUMAN\" }\n  | { type: \"HUMAN_CLAIMED\"; agentId: string }\n  | { type: \"HUMAN_RELEASED\" }\n  | { type: \"END\" };\n\nexport type Effect =\n  | {\n      type: \"GENERATE\";\n      epoch: number;\n      turnId: string;\n      sourceMessageId: string;\n    }\n  | { type: \"MODERATE\"; epoch: number; turnId: string; text: string }\n  | {\n      type: \"SEND_BOT\";\n      epoch: number;\n      turnId: string;\n      clientMessageId: string;\n      text: string;\n    }\n  | {\n      type: \"RECONCILE_DELIVERY\";\n      epoch: number;\n      turnId: string;\n      clientMessageId: string;\n    }\n  | { type: \"NOTIFY_HUMANS\"; conversationId: string }\n  | { type: \"SHOW_STATUS\"; text: string };\n\nexport type Result = {\n  state: State;\n  effects: Effect[];\n};\n\nexport const initialState = (conversationId: string): State => ({\n  conversationId,\n  mode: \"consent_required\",\n  epoch: 0,\n});\n\nfunction matchesActiveTurn(\n  state: State,\n  event: { epoch: number; turnId: string },\n): boolean {\n  return state.epoch === event.epoch && state.turnId === event.turnId;\n}\n\nfunction requestHandoff(state: State): Result {\n  const next: State = {\n    conversationId: state.conversationId,\n    mode: \"handoff_pending\",\n    // Incrementing the epoch makes every unfinished callback stale.\n    epoch: state.epoch + 1,\n  };\n\n  return {\n    state: next,\n    effects: [\n      { type: \"NOTIFY_HUMANS\", conversationId: state.conversationId },\n      { type: \"SHOW_STATUS\", text: \"A person has been requested.\" },\n    ],\n  };\n}\n\nexport function reduce(state: State, event: Event): Result {\n  if (state.mode === \"ended\") {\n    return { state, effects: [] };\n  }\n\n  if (event.type === \"END\") {\n    return {\n      state: {\n        conversationId: state.conversationId,\n        mode: \"ended\",\n        epoch: state.epoch + 1,\n      },\n      effects: [],\n    };\n  }\n\n  if (event.type === \"REQUEST_HUMAN\") {\n    return requestHandoff(state);\n  }\n\n  if (event.type === \"USER_OPTED_IN\" && state.mode === \"consent_required\") {\n    return {\n      state: { ...state, mode: \"ai_ready\", epoch: state.epoch + 1 },\n      effects: [{ type: \"SHOW_STATUS\", text: \"AI assistance is on.\" }],\n    };\n  }\n\n  if (event.type === \"USER_MESSAGE\" && state.mode === \"ai_ready\") {\n    if (event.requiresHuman) return requestHandoff(state);\n\n    const epoch = state.epoch + 1;\n    const turnId = `turn:${event.messageId}`;\n\n    return {\n      state: { ...state, mode: \"ai_generating\", epoch, turnId },\n      effects: [\n        {\n          type: \"GENERATE\",\n          epoch,\n          turnId,\n          sourceMessageId: event.messageId,\n        },\n      ],\n    };\n  }\n\n  if (\n    event.type === \"AI_DRAFTED\" &&\n    state.mode === \"ai_generating\" &&\n    matchesActiveTurn(state, event)\n  ) {\n    return {\n      state: {\n        ...state,\n        mode: \"moderating\",\n        draft: { turnId: event.turnId, text: event.text },\n      },\n      effects: [\n        {\n          type: \"MODERATE\",\n          epoch: event.epoch,\n          turnId: event.turnId,\n          text: event.text,\n        },\n      ],\n    };\n  }\n\n  if (\n    event.type === \"MODERATION_APPROVED\" &&\n    state.mode === \"moderating\" &&\n    state.draft &&\n    matchesActiveTurn(state, event)\n  ) {\n    const clientMessageId = `ai:${state.conversationId}:${event.turnId}`;\n\n    return {\n      state: { ...state, mode: \"bot_sending\" },\n      effects: [\n        {\n          type: \"SEND_BOT\",\n          epoch: event.epoch,\n          turnId: event.turnId,\n          clientMessageId,\n          text: state.draft.text,\n        },\n      ],\n    };\n  }\n\n  if (\n    event.type === \"MODERATION_REJECTED\" &&\n    matchesActiveTurn(state, event)\n  ) {\n    return requestHandoff(state);\n  }\n\n  if (\n    event.type === \"BOT_DELIVERED\" &&\n    state.mode === \"bot_sending\" &&\n    matchesActiveTurn(state, event)\n  ) {\n    return {\n      state: {\n        conversationId: state.conversationId,\n        mode: \"ai_ready\",\n        epoch: state.epoch,\n      },\n      effects: [],\n    };\n  }\n\n  if (\n    event.type === \"BOT_DELIVERY_UNKNOWN\" &&\n    state.mode === \"bot_sending\" &&\n    matchesActiveTurn(state, event)\n  ) {\n    return {\n      state: { ...state, mode: \"delivery_unknown\" },\n      effects: [\n        {\n          type: \"RECONCILE_DELIVERY\",\n          epoch: event.epoch,\n          turnId: event.turnId,\n          clientMessageId: `ai:${state.conversationId}:${event.turnId}`,\n        },\n        { type: \"SHOW_STATUS\", text: \"Checking message delivery…\" },\n      ],\n    };\n  }\n\n  if (\n    event.type === \"DELIVERY_RECONCILED\" &&\n    state.mode === \"delivery_unknown\" &&\n    matchesActiveTurn(state, event)\n  ) {\n    if (event.delivered) {\n      return {\n        state: {\n          conversationId: state.conversationId,\n          mode: \"ai_ready\",\n          epoch: state.epoch,\n        },\n        effects: [],\n      };\n    }\n\n    // Do not regenerate after an uncertain send. Move to a person instead.\n    return requestHandoff(state);\n  }\n\n  if (event.type === \"HUMAN_CLAIMED\" && state.mode === \"handoff_pending\") {\n    return {\n      state: {\n        conversationId: state.conversationId,\n        mode: \"human_active\",\n        epoch: state.epoch + 1,\n        agentId: event.agentId,\n      },\n      effects: [{ type: \"SHOW_STATUS\", text: \"A person joined the DM.\" }],\n    };\n  }\n\n  if (event.type === \"HUMAN_RELEASED\" && state.mode === \"human_active\") {\n    return {\n      state: {\n        conversationId: state.conversationId,\n        mode: \"consent_required\",\n        epoch: state.epoch + 1,\n      },\n      effects: [\n        {\n          type: \"SHOW_STATUS\",\n          text: \"Human assistance ended. Choose whether to use AI again.\",\n        },\n      ],\n    };\n  }\n\n  // Late, duplicate, or invalid events are deliberately ignored.\n  return { state, effects: [] };\n}\n```\n\nThe `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.\n\nThis is stronger than trying to cancel an HTTP request. Cancellation is an optimization; rejecting stale results is the correctness mechanism.\n\nCreate `src/coordinator.test.ts`:\n\n``` js\nimport { describe, expect, it } from \"vitest\";\nimport { initialState, reduce } from \"./coordinator\";\n\nfunction startAiTurn() {\n  let state = reduce(initialState(\"dm-42\"), {\n    type: \"USER_OPTED_IN\",\n  }).state;\n\n  state = reduce(state, {\n    type: \"USER_MESSAGE\",\n    messageId: \"msg-1\",\n    requiresHuman: false,\n  }).state;\n\n  return state;\n}\n\ndescribe(\"DM reply authority\", () => {\n  it(\"rejects a model result that arrives after handoff\", () => {\n    const generating = startAiTurn();\n    const epoch = generating.epoch;\n    const turnId = generating.turnId!;\n\n    const handedOff = reduce(generating, {\n      type: \"REQUEST_HUMAN\",\n    }).state;\n\n    const late = reduce(handedOff, {\n      type: \"AI_DRAFTED\",\n      epoch,\n      turnId,\n      text: \"This must never be sent.\",\n    });\n\n    expect(late.state.mode).toBe(\"handoff_pending\");\n    expect(late.effects).toEqual([]);\n  });\n\n  it(\"does not send a draft before moderation\", () => {\n    const generating = startAiTurn();\n\n    const drafted = reduce(generating, {\n      type: \"AI_DRAFTED\",\n      epoch: generating.epoch,\n      turnId: generating.turnId!,\n      text: \"Candidate response\",\n    });\n\n    expect(drafted.state.mode).toBe(\"moderating\");\n    expect(drafted.effects[0]?.type).toBe(\"MODERATE\");\n    expect(drafted.effects.some((effect) => effect.type === \"SEND_BOT\")).toBe(false);\n  });\n\n  it(\"hands off when moderation is unavailable\", () => {\n    const generating = startAiTurn();\n    const drafted = reduce(generating, {\n      type: \"AI_DRAFTED\",\n      epoch: generating.epoch,\n      turnId: generating.turnId!,\n      text: \"Candidate response\",\n    }).state;\n\n    const failed = reduce(drafted, {\n      type: \"MODERATION_REJECTED\",\n      epoch: drafted.epoch,\n      turnId: drafted.turnId!,\n      reason: \"unavailable\",\n    });\n\n    expect(failed.state.mode).toBe(\"handoff_pending\");\n    expect(failed.effects[0]?.type).toBe(\"NOTIFY_HUMANS\");\n  });\n\n  it(\"requires new consent after the human leaves\", () => {\n    let state = startAiTurn();\n    state = reduce(state, { type: \"REQUEST_HUMAN\" }).state;\n    state = reduce(state, {\n      type: \"HUMAN_CLAIMED\",\n      agentId: \"agent-7\",\n    }).state;\n    state = reduce(state, { type: \"HUMAN_RELEASED\" }).state;\n\n    expect(state.mode).toBe(\"consent_required\");\n  });\n\n  it(\"reconciles an uncertain send instead of sending again\", () => {\n    let state = startAiTurn();\n    state = reduce(state, {\n      type: \"AI_DRAFTED\",\n      epoch: state.epoch,\n      turnId: state.turnId!,\n      text: \"Approved later\",\n    }).state;\n    state = reduce(state, {\n      type: \"MODERATION_APPROVED\",\n      epoch: state.epoch,\n      turnId: state.turnId!,\n    }).state;\n\n    const unknown = reduce(state, {\n      type: \"BOT_DELIVERY_UNKNOWN\",\n      epoch: state.epoch,\n      turnId: state.turnId!,\n    });\n\n    expect(unknown.state.mode).toBe(\"delivery_unknown\");\n    expect(unknown.effects[0]?.type).toBe(\"RECONCILE_DELIVERY\");\n    expect(unknown.effects.some((effect) => effect.type === \"SEND_BOT\")).toBe(false);\n  });\n});\n```\n\nRun the suite:\n\n```\nnpm test\n```\n\nThese tests verify policy, not model quality. That distinction matters. A fluent answer can still be invalid because it arrived after a person took ownership.\n\nThe 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.\n\n```\ntype MessageAuthor = \"user\" | \"ai\" | \"human\" | \"system\";\n\ntype OutgoingMessage = {\n  conversationId: string;\n  clientMessageId: string;\n  author: MessageAuthor;\n  text: string;\n  replyToMessageId?: string;\n};\n\ninterface ChatPort {\n  send(message: OutgoingMessage): Promise<\n    | { outcome: \"delivered\"; serverMessageId: string }\n    | { outcome: \"unknown\" }\n    | { outcome: \"failed\"; retryable: boolean }\n  >;\n\n  findByClientMessageId(\n    conversationId: string,\n    clientMessageId: string,\n  ): Promise<{ delivered: boolean }>;\n}\n\ninterface ModelPort {\n  draft(input: {\n    conversationId: string;\n    sourceMessageId: string;\n    context: readonly ContextMessage[];\n  }): Promise<{ text: string }>;\n}\n\ninterface ModerationPort {\n  review(text: string): Promise<\n    | { decision: \"approved\" }\n    | { decision: \"blocked\" }\n    | { decision: \"unavailable\" }\n  >;\n}\n\ntype ContextMessage = {\n  messageId: string;\n  author: \"user\" | \"ai\" | \"human\";\n  text: string;\n  consentedForAi: boolean;\n};\n```\n\nThere are three important implementation details here.\n\nDo 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.\n\nThe interface should also show transitions such as:\n\nThese are not decorative status messages. They expose the authority state users otherwise have to guess.\n\nThe reducer prevents two claims in one local event sequence, but two agents can still click **Claim** concurrently from separate devices.\n\nPersist a lease similar to:\n\n```\ntype ReplyLease = {\n  conversationId: string;\n  ownerType: \"ai\" | \"human\";\n  ownerId: string;\n  version: number;\n};\n```\n\nThe 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.\n\nDo not rely on a disabled button for this. UI state cannot serialize distributed claims.\n\nA handoff does not imply that every historical message should be sent to a model. Build context from an explicit policy:\n\n```\nexport function compileAiContext(\n  messages: readonly ContextMessage[],\n  maximumMessages: number,\n): ContextMessage[] {\n  return messages\n    .filter((message) => message.consentedForAi)\n    .filter((message) => message.author !== \"human\")\n    .slice(-maximumMessages);\n}\n```\n\nExcluding 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.\n\nDo 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.\n\nA practical ordering is:\n\nA model recommendation should become an event for the coordinator, not an invisible transfer of control inside a prompt.\n\nThis 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.\n\nMultilingual DMs introduce another tempting shortcut: feeding translated text back into the assistant as though it were the original message.\n\nAvoid 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.\n\nTencent 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.\n\nFor AI context, choose one explicit policy:\n\nDo 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.\n\nA convincing happy-path demo is not enough. Reproduce these cases while recording the state, epoch, turn ID, client message ID, and visible UI status.\n\nExpected result:\n\n`handoff_pending` immediately.`unavailable` from `blocked`.` delivery_unknown`.` clientMessageId` is used for reconciliation.`consent_required`.\nBefore connecting the workflow to production DMs, verify:\n\nThe 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.\n\nA reliable DM assistant earns that predictability through visible state and narrow authority—not through a more convincing personality.\n\n**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.\n\nWhere 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?", "url": "https://wpnews.pro/news/when-an-ai-leaves-the-dm-make-the-handoff-atomic", "canonical_source": "https://dev.to/susiewang/when-an-ai-leaves-the-dm-make-the-handoff-atomic-4771", "published_at": "2026-09-11 17:11:54+00:00", "updated_at": "2026-09-11 17:43:47.007113+00:00", "lang": "en", "topics": ["ai-agents", "ai-products", "developer-tools", "natural-language-processing"], "entities": ["Tencent RTC", "TypeScript", "Vitest", "Tencent"], "alternates": {"html": "https://wpnews.pro/news/when-an-ai-leaves-the-dm-make-the-handoff-atomic", "markdown": "https://wpnews.pro/news/when-an-ai-leaves-the-dm-make-the-handoff-atomic.md", "text": "https://wpnews.pro/news/when-an-ai-leaves-the-dm-make-the-handoff-atomic.txt", "jsonld": "https://wpnews.pro/news/when-an-ai-leaves-the-dm-make-the-handoff-atomic.jsonld"}}