fullstackhero/dotnet-starter-kit · warning · CustomException

Cannot reply to a reply — threads are single-level only.

Error message

Cannot reply to a reply — threads are single-level only.

What it means

Chat threads are one level deep: the handler rejects replying to a message that is itself a reply (parent.ParentMessageId.HasValue) with a 400 CustomException. Only top-level messages can be replied to.

Solutions

  1. Only enable reply on messages where parentMessageId is null.
  2. When a reply to a reply is attempted, retarget the request to the thread's root message id instead.
  3. Render child replies with a 'View thread' action pointing to the root, not a reply action.
  4. Validate parentId depth client-side before submitting.

Example fix

// before
const target = msg.parentMessageId ?? msg.id;
// after
// retarget reply to the thread root (already the fix) or:
if (msg.parentMessageId) disableReplyButton(msg);
Defensive patterns

Strategy: validation

Validate before calling

if (parent?.parentMessageId) throw new Error('Threads are single-level; reply to the root message instead');

Type guard

const isThreadRoot = (m) => m.parentMessageId == null;
// only show Reply when isThreadRoot(m)

Try / catch

try { await sendReply(cmd); }
catch (e) { if (e.status === 400 && /single-level/.test(e.message)) { retargetToRootAndRetry(); } else throw e; }

Prevention

When it happens

Trigger: Passing a ParentMessageId that points to an already-nested reply — e.g. a UI that renders a 'reply' button on every message in a thread including child replies, or an API client walking a thread recursively.

Common situations: Threaded view rendering reply buttons on child messages, automation or tests using a child message id as parent, or a client model that doesn't distinguish parent vs reply messages.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/0260345f0eccdcb0. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs:54

            .ConfigureAwait(false)
            ?? throw new NotFoundException("Channel not found.");

        channel.RequireMember(currentUserId);

        Message? parent = null;
        if (cmd.ParentMessageId is { } parentId)
        {
            parent = await db.Messages.FirstOrDefaultAsync(m => m.Id == parentId, cancellationToken)
                .ConfigureAwait(false)
                ?? throw new NotFoundException("Parent message not found.");
            if (parent.ChannelId != channel.Id)
            {
                throw new CustomException("Parent message belongs to a different channel.", (IEnumerable<string>?)null, HttpStatusCode.BadRequest);
            }
            if (parent.ParentMessageId.HasValue)
            {
                // 1-level deep only per spec.
                throw new CustomException("Cannot reply to a reply — threads are single-level only.", (IEnumerable<string>?)null, HttpStatusCode.BadRequest);
            }
        }

        // Parse @username tokens to user ids; self-mentions and unresolved tokens are dropped silently.
        // Only resolved *other* users attach as MessageMention rows + fire an event. Body may be empty.
        var rawMatches = MentionParser.Parse(cmd.Body ?? string.Empty);
        var distinctNames = rawMatches.Select(m => m.Username)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToArray();
        var resolved = distinctNames.Length == 0
            ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
            : (Dictionary<string, string>)await mentionResolver
                .ResolveUserIdsAsync(distinctNames, cancellationToken)
                .ConfigureAwait(false);

        var parsedMentions = new List<Message.ParsedMention>();
        var notifyUserIds = new HashSet<string>(StringComparer.Ordinal);
        foreach (var match in rawMatches)

View on GitHub (pinned to 3f2959e683)