Skip to content

Refactor: processMessage is a ~650 line monolith with fragile state management #532

@ezra-letta

Description

@ezra-letta

Problem

processMessage (lines 1066-1743 in src/core/bot.ts) handles too many concerns in one procedural flow with 20+ mutable closure variables acting as an implicit state machine. This makes it fragile, hard to reason about, and prone to subtle bugs when new recovery paths are added.

Current structure

The method handles all of these in a single for await loop with shared mutable state:

  1. Stream consumption and type routing (~80 lines)
  2. Foreground run identification and competing run filtering (~40 lines)
  3. Multi-assistant-UUID detection and message finalization (~50 lines)
  4. Live-edit streaming with rate limit backoff (~30 lines)
  5. Result text vs streamed text divergence resolution (~30 lines)
  6. Stale/cancelled run detection and retry (~30 lines)
  7. Approval conflict recovery (2 separate paths, ~50 lines)
  8. Empty/error result retry with orphaned approval enrichment (~50 lines)
  9. Missing foreground terminal result detection (~15 lines)
  10. Directive parsing (~10 lines)
  11. Final message delivery with edit-fallback-to-new (~20 lines)
  12. No-visible-response fallback (~10 lines)

Specific fragility points

1. response buffer is a footgun

Cleared in at least 5 places (lines 1212, 1243, 1459, 1659, 1673) and set from 3 different sources (streamed assistant chunks, result text fallback, error formatting). sentAnyMessage is supposed to be the authoritative "did we deliver" flag, but finalizeMessage() clears response silently, so later checks like hasResponse (line 1493) can be false even though text was already delivered to the user.

2. The result handler (lines 1437-1630) is the most complex section

  • 5-way decision tree for whether to use result text vs streamed text
  • Calls buildResultRetryDecision twice (lines 1531 and 1554) with an API enrichment call in between
  • 3 separate retry paths (approval conflict, empty result, error result), each invalidating the session and recursively calling processMessage
  • Recursion is bounded by retried but state carry-across is hard to verify

3. Run ID tracking is defensive but opaque

expectedForegroundRunId is set on the first assistant or result event. Everything before that gets buffered/deferred. The system can't know which run to display until content is already streaming, so the buffer-then-flush pattern adds complexity to every downstream consumer.

4. The sentAnyMessage / response / messageId trio is an implicit state machine

Possible states (not explicitly modeled):

  • No response yet
  • Streaming via edits (messageId set)
  • Finalized mid-stream (response cleared, sentAnyMessage true)
  • Finalized at end
  • Cancelled
  • No-reply marker
  • Error-formatted response

These states are implicit in the combination of variables, not explicit in the code.

Suggested decomposition

Split into focused components with explicit state transitions:

  1. StreamConsumer -- consumes raw stream, handles run filtering and deduplication, yields semantic events
  2. ResponseAccumulator -- tracks response text, handles multi-turn finalization, owns the response/messageId/sentAnyMessage state as explicit states
  3. ResultHandler -- receives result event, makes retry/recovery decisions, returns an action rather than executing side effects inline
  4. DeliveryManager -- handles streaming edits, final send, edit-fallback, rate limiting

Each as a class or module with well-defined inputs/outputs rather than 20+ mutable variables in a closure.

Impact

This isn't causing user-visible bugs right now (the current code works), but every new recovery path or edge case handler added to this method increases the risk of state interaction bugs. The PR #513 fix (run filtering) added ~100 lines of state tracking to an already complex method. Future changes (e.g. multi-message responses, richer streaming, new error recovery) will compound this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions