ライブ配信の不具合を会話だけでPR化しない:イベントカプセルで共同配信の遅着バグを再現する #AI - Qiita
@susie000720

Don't turn live-streaming defects into PRs based on conversation alone: Reproduce the late-arriving-event bug in co-streaming with an Event Capsule

  • TencentRTC
  • TypeScript
  • AI
  • Live Streaming
0
Last updated at 2026-11-10Posted at 2026-11-10
0
0

During live operations, a report may say, “We canceled the co-stream, but a few seconds later the display returned to ‘participating.’” Even then, all that remains in the thread is the outcome as seen by a human.

If you hand that straight to an AI agent to implement, it can produce a plausible fix PR, but the following information will be missing.
  • Whether the cancellation or the approval notification arrived first
  • Whether the media was actually published or only the UI changed
  • Whether events from before the reconnection were mixed in

Whether a human’s cancellation action should take precedence over a late-arriving event

This is not so much a problem with AI’s summarization ability as a lack of a “timeline” in the input.

In this article, we convert Interactive Live events into compact reproduction data and connect the entire workflow, from task creation and AI-generated fix candidates to a GitHub PR and human review. Because it does not assume Tencent RTC-specific SDK API names and uses normalized in-app events as the boundary, it can be adapted to the platform you are using.

Conclusion: Make the sequence of events before and after the operation—not the thread—the source of truth for the task

  1. What we are building this time is the Incident Capsule. We will record what happened on the live screen in JSON, along with the following three items:
  2. Normalized events that preserve arrival order

The expected outcome determined by a human

A hash to confirm that the content has not changed after recording

The development flow is as follows:
  • Live screen ↓ Normalize UI actions, RTC notifications, and app state Short-lived ring buffer ↓ The operations staff member selects “Save incident” Incident Capsule ↓ A trusted process registers it in the repository Task + replay test ↓ AI agent changes only the implementation code ↓ Replay the event sequence in CI ↓ A human reviews and merges the PR
  • AI can improve the part where it identifies the race condition in the event sequence and proposes a fix. The following judgments, however, will not be delegated to AI.

Whether cancellation or approval should take precedence

Which UI state should be called “co-streaming”

  • Whether human actions may be overridden in production
  • Can we modify the reproduction test itself to make it pass?
  • Prerequisite: First check the built-in approach for Interactive Live

    With Tencent RTC Live Streaming, you can choose to use a prebuilt UI or headless AtomicXCore integration for more granular assembly. The official setup guide is as follows.

    The full range of features supported by Interactive Live, such as viewing, chat, co-hosting, and viewer management, can be found in the following official documentation.

    The selection criteria for this task are simple.

    Condition Approach
    Use a prebuilt live UI as the foundation Record operational actions and required notifications outside TUILiveKit
    When there is a custom co-hosting UI or many state transitions Consider an architecture that uses AtomicXCore and explicitly define a normalization layer
    If the goal is initially to improve only the reproducibility of bug reports Introduce it through a recording adapter without changing the built-in approach

    Terms such as cohost.accepted are not Tencent RTC API names, but normalized events internal to the app. The actual SDK callback names are mapped in the adapter according to the official documentation for the target platform.

    Step 1: Create a minimal project

    This assumes Node.js 20 or later.

    mkdir live-incident-capsule cd live-incident-capsule npm init -y npm install -D typescript tsx @types/node mkdir -p src test incidents

    Add the test commands to package.json.

    { "type": "module", "scripts": { "test": "tsx --test test/**/*.test.ts" }, "devDependencies": { "@types/node": "^20.0.0", "tsx": "^4.0.0", "typescript": "^5.0.0" } }

    Step 2: Normalize live events

    Create src/events.ts.

    export type LiveEventKind = | "cohost.requested" | "cohost.accepted" | "cohost.cancelled" | "media.published"| "media.unpublished" | "ui.cohost_active" | "room.reconnected"; export type EventSource = "operator-ui" | "rtc-adapter" | "app"; export type LiveEvent = { // Arrival order assigned by the recorder. During playback, this takes precedence over the timestamp. seq: number; receivedAtMs: number; roomSessionId: string; operationId: string; subjectId: string; source: EventSource; kind: LiveEventKind;actor: "human" | "system" | "remote-member"; }; export type ExpectedOutcome = { rule: "cancelled-operation-must-not-become-active"; operationId: string; description: string; }; export type IncidentCapsuleCore = { schemaVersion: 1; incidentId: string; createdAt: string; roomSessionId: string; sourceThreadUrl?: string; expected: ExpectedOutcome; events: LiveEvent[]; };export type IncidentCapsule = IncidentCapsuleCore & { sha256: string; };

    operationId is the unit of a joint distribution request, and roomSessionId identifies a single room-entry/reconnection interval. Processing by user ID alone makes it impossible to distinguish repeated requests from the same participant or notifications from before a reconnection.

    Also, seq is used for playback order instead of receivedAtMs. This is because sorting by device time or server time would hide the actual arrival order.

    Step 3: Implement a ring buffer that stores data from before and after operations

    Create src/recorder.ts.

  • import { createHash, randomUUID } from "node:crypto"; import type { ExpectedOutcome, IncidentCapsule, IncidentCapsuleCore, LiveEvent, } from "./events.js"; function stableJson(value: unknown): string {if (Array.isArray(value)) { return `[${value.map(stableJson).join(",")}]`; } if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; const keys = Object.keys(record).sort(); return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) .join(",")}}`; } return JSON.stringify(value); } export class IncidentRecorder { private events: LiveEvent[] = []; private nextSeq = 1; constructor(private readonly maxEvents = 500) {}record(event: Omit<LiveEvent, "seq" | "receivedAtMs">): LiveEvent { const stored: LiveEvent = { ...event, seq: this.nextSeq++, receivedAtMs: Date.now(), }; this.events.push(stored); if (this.events.length > this.maxEvents) { this.events.shift(); } return stored; } createCapsule(input: { roomSessionId: string; expected: ExpectedOutcome; sourceThreadUrl?: string; }): IncidentCapsule { const relevantEvents = this.events .filter((event) => event.roomSessionId === input.roomSessionId) .map((event) => ({ ...event })); const core: IncidentCapsuleCore = {schemaVersion: 1, incidentId: randomUUID(), createdAt: new Date().toISOString(), roomSessionId: input.roomSessionId, sourceThreadUrl: input.sourceThreadUrl, expected: input.expected, events: relevantEvents, }; const sha256 = createHash("sha256") .update(stableJson(core)) .digest("hex");return { ...core, sha256 }; } } export function verifyCapsule(capsule: IncidentCapsule): boolean { const { sha256, ...core } = capsule; const actual = createHash("sha256") .update(stableJson(core)) .digest("hex"); return actual === sha256; }
  • The maximum number of events is an app-side setting. It is not a Tencent RTC limit or performance value. In actual operation, determine it by measuring the device's memory, the number of events generated, and the investigation time required.

    SDK integration point

    Notifications and UI operations on the Tencent RTC side must not be stored as-is. Differences between platforms are absorbed by an adapter.

    const recorder = new IncidentRecorder(); // In practice, this is called from the callback of the SDK being used function onRtcCohostAccepted(input: { roomSessionId: string; operationId: string; memberId: string; }) { recorder.record({ roomSessionId: input.roomSessionId, operationId: input.operationId, subjectId: pseudonymize(input.memberId), source: "rtc-adapter", kind: "cohost.accepted",actor: "remote-member", }); } // Record at the moment the operator cancels function onOperatorCancel(input: { roomSessionId: string; operationId: string; memberId: string; }) { recorder.record({ roomSessionId: input.roomSessionId, operationId: input.operationId, subjectId: pseudonymize(input.memberId), source: "operator-ui", kind: "cohost.cancelled",actor: "human", }); } function pseudonymize(rawId: string): string { // For demonstration purposes. In production, consider using an HMAC or similar approach with a server-managed secret value. return `member:${rawId.length}`; }

    The example pseudonymize is not sufficient for anonymization. Here, it only demonstrates the boundary rule: "Do not put raw participant IDs directly into events."

    Step 4: Create an oracle that replays the event sequence

    The expected behavior in this case is as follows:

    After a human cancels a given operationId, a late approval notification for that operation alone must not cause the human to return to co-hosting.

    Create src/replay.ts.

    import type { IncidentCapsule, LiveEvent } from "./events.js"; export type CohostState = {phase: "idle" | "requested" | "accepted" | "active" | "cancelled"; operationId?: string; mediaPublished: boolean; violations: string[]; }; const initialState = (): CohostState => ({ phase: "idle", mediaPublished: false, violations: [], }); export function reduceEvent( state: CohostState,event: LiveEvent, ): CohostState { // A late-arriving event received for the same already-cancelled operation does not advance the state if ( state.phase === "cancelled" && state.operationId === event.operationId && (event.kind === "cohost.accepted" || event.kind === "media.published" || event.kind === "ui.cohost_active") ) { return { ...state, violations:event.kind === "ui.cohost_active" ? [ ...state.violations, `cancelled operation became active at seq=${event.seq}`, ] : state.violations, }; } switch (event.kind) { case "cohost.requested": return { ...state, phase: "requested", operationId: event.operationId, mediaPublished: false, }; case "cohost.accepted": return { ...state, phase: "accepted", operationId: event.operationId, }; case "cohost.cancelled": return { ...state, phase: "cancelled", operationId: event.operationId, mediaPublished: false, }; case "media.published": return { ...state,operationId: event.operationId, mediaPublished: true, }; case "media.unpublished": return { ...state, mediaPublished: false }; case "ui.cohost_active": if (!state.mediaPublished) { return { ...state, violations: [ ...state.violations, `UI became active before media publication at seq=${event.seq}`, ], }; }return { ...state, phase: "active" }; case "room.reconnected": return initialState(); } } export function replay(capsule: IncidentCapsule): CohostState { return [...capsule.events] .sort((a, b) => a.seq - b.seq) .reduce(reduceEvent, initialState()); }

    Here, "approved" and "media has been released" are distinguished. If the UI is marked as streaming based solely on the co-streaming approval notification, it cannot represent cases where the media has not yet been released or where cancellations race with each other.

    Step 5: Lock in the failing fixture

    Create test/late-accept.test.ts.

    import assert from "node:assert/strict"; import test from "node:test"; import type { IncidentCapsule } from "../src/events.js"; import { replay } from "../src/replay.js"; const capsule: IncidentCapsule = { schemaVersion: 1, incidentId: "incident-late-accept-001", createdAt: "2026-11-10T10:00:00.000Z",roomSessionId: "room-session-a", expected: { rule: "cancelled-operation-must-not-become-active", operationId: "op-42", description: "Does not return to co-streaming after a delayed approval following cancellation of the operation", }, events: [ { seq: 1, receivedAtMs: 1000, roomSessionId: "room-session-a", operationId: "op-42", subjectId: "member:pseudo-a", source: "app",kind: "cohost.requested", actor: "system", }, { seq: 2, receivedAtMs: 1100, roomSessionId: "room-session-a", operationId: "op-42", subjectId: "member:pseudo-a", source: "operator-ui", kind: "cohost.cancelled", actor: "human", }, { seq: 3,receivedAtMs: 1200, roomSessionId: "room-session-a", operationId: "op-42", subjectId: "member:pseudo-a", source: "rtc-adapter", kind: "cohost.accepted", actor: "remote-member", }, { seq: 4, receivedAtMs: 1300, roomSessionId: "room-session-a", operationId: "op-42",subjectId: "member:pseudo-a", source: "app", kind: "ui.cohost_active", actor: "system", }, ], sha256: "In the fixture, store the value calculated by the registration process", }; test("Does not return to active on a late-arriving event after cancellation", () => { const result = replay(capsule); assert.equal(result.phase, "cancelled");assert.deepEqual(result.violations, [ "cancelled operation became active at seq=4", ]); });

    This test does not assume that the problematic event never arrived. After observing the late-arriving event, it verifies that the state of the cancelled operation does not advance.

    In the actual app code, the same check as reduceEvent is applied before updating the UI state. Rather than ignoring SDK notifications, it determines whether they can be reflected in the app state using roomSessionId and operationId.

    Boundaries from the task through the AI agent to the PR

    Merely pasting the Incident Capsule into the AI prompt is not sufficient. If the AI is also allowed to modify tests or expected results, this becomes self-grading rather than a fix.

    The recommended order is as follows:

    1. The operations staff save the incident
    2. A trusted bot or operator registers the Capsule in incidents/
    3. A human reviews expected.description
    4. First, merge the Capsule and the reproduction test into the base branch
    5. Create a branch for the AI agent from that commit
    6. The AI modifies only the implementation in src/
    7. Use CI to verify the protected target and the replay results
    8. A human reviews the diff and the behavior on the actual device

    The information passed to the task is also separated.

    { "title": "After canceling a joint broadcast, the UI returns to active due to a late-arriving approval", "sourceThreadUrl": "https://chat.example.invalid/thread/123","incidentPath": "incidents/incident-late-accept-001.json", "expectedRule": "cancelled-operation-must-not-become-active", "agentScope": ["src/cohost-state.ts", "test/implementation/**"], "protectedPaths": ["incidents/**", "test/oracles/**"] }

    Instructions for the AI should specify the prohibited modification areas more clearly than the implementation approach.

    Replay the Incident Capsules in seq order and explain the cause of the failure. Please create candidate fixes to the implementation code. Do not modify incidents/ or test/oracles/. Keep specification changes that overwrite the human `cancelled` event as proposals only; do not implement them.

    Reject changes to evidence files in CI

    The branch for AI is assumed to be created from the base commit after Capsule registration.

    #!/usr/bin/env bash set -euo pipefail git fetch origin main protected=$(git diff --name-only origin/main...HEAD \| grep -E '^(incidents/|test/oracles/)' || true) if [ -n "$protected" ]; then echo "Protected incident evidence was modified:" echo "$protected" exit 1 fi npm test

    Hashes can be used to detect accidental corruption, but they do not independently guarantee protection against tampering. Because files and hashes can be rewritten at the same time, use them in combination with branch protection, CODEOWNERS, and CI path checks.

    Decision Table for the Scope of AI Delegation

    Task Delegate to AI Final decision
    Summarizing the event sequence Allowed A person cross-checks it against the source data
    Searching for Reducer branches that are likely to conflict Allowed A person determines where to adopt them
    Creating implementation patches Allowed CI and review required
    Determining expected outcomes Not allowed Operations/product owner
    Rewriting the Incident Capsule Not allowed Only through a trusted registration path
    Disabling joint delivery in the production live environment Not allowed Maintain a human-operable path
    Merging PRsNot possible Have a person check the diff and behavior on an actual device

    AI is well suited for creating candidates from observed events. However, the product judgment that “it is more natural to prioritize approval even after cancellation” cannot be derived from code-generation capabilities.

    Verification method

    1. Automated testing

    npm test

    At a minimum, add the following cases as fixtures.

    • requested → cancelled → accepted → ui.active
    • requested → accepted → media.published → ui.active
    • The same accepted event arrives more than once
    • An operationId from before the reconnection arrives after the reconnection
    • A cancellation for a different operationId is mixed in
    • ui.active is recorded before media.published

    2. Disrupt the operation order on two or more devices

    Intentionally try the following operations, not just the happy path.

    1. Initiate a joint-streaming request on the broadcaster’s side
    2. Cancel on the operator’s side immediately before or after the other party performs the approval operation
    3. Switch networks, or resume the app from the background
    4. Check the UI display and the actual media publication status separately
    5. Check whether the seq of the saved Capsule preserves the observation order

    A specific latency in seconds is not used as a pass condition because it varies with device and network conditions. The first thing to verify is that an operation canceled by a human never comes back to life, regardless of the order.

    3. PR review

    • The branch is created from the commit after Capsule registration
    • incidents/ and the oracle have not been modified
    • SDK notification names do not leak throughout the app and are normalized by the adapter
    • Both roomSessionId and operationId are checked
    • The UI’s active indication does not appear before media publication
    • A human cancellation operation can be re-executed
    • A person has checked the event sequence, not just the AI-generated explanation

    Notes

    Do not continuously store chat content or audio

    If what is needed for reproduction is the state transition, it is safer not to mix message content, audio, gift details, and the like into the Incident Capsule. Limit it to the minimum necessary event types, pseudonymized actors, and operation IDs.

    For a different type of investigation that requires message content, separately design the consent, retention period, access permissions, and deletion procedure.

    Do not assume that network degradation is the cause

    Even if the issue occurs before or after a period of poor network connectivity, that alone does not make it the cause. Record network status as a diagnostic event, separately from the pass/fail criteria for state transitions.

    Do not infer TUILiveKit's internal state

    Even when using the prebuilt UI, avoid creating tests that infer the internal state from its appearance. Normalize the public notifications, moderation operations, and media states that the app can observe. If deeper control is required, reconsider the embedding approach itself based on the official setup guide.

    Even if the AI's PR passes, the production go/no-go decision remains

    A replay test guarantees the app's decision-making for the recorded event sequence. It does not guarantee unobserved SDK behavior, the OS lifecycle, or the display on another device. Even after CI passes, verify on an actual device that the human cancellation flow works.

    Summary

    When turning a live-streaming bug from a thread into a task, simply summarizing the conversation cleanly will lose the timeline.

    If you first pin down the event sequence and expected outcomes as an Incident Capsule, the AI agent can propose implementation candidates but cannot arbitrarily change the observed facts or pass/fail criteria. What matters is not whether AI is used, but maintaining the following boundary.

    • Normalize Tencent RTC notifications into app events
    • Preserve the arrival order and operation granularity
    • A human determines the expected outcome
    • Register the evidence before creating an AI branch
    • Allow only the implementation code to be changed in the PR
    • Finally, verify the human interaction flow on an actual device

    This enables a transition from a state where "the report is plausible but cannot be reproduced" to a development workflow in which the same event sequence can be replayed any number of times.

    Relationship disclosure: The author wrote this article from the standpoint of introducing Tencent RTC technical information and used the official Tencent RTC documentation as an implementation reference.

    0
    0
    0

    Register as a new user and use Qiita more conveniently

    1. You get articles that match your needs
    2. You can efficiently read back useful information
    3. You can use dark theme
    What you can do with signing up
    Sign upLogin