Skip to content

feat: extend AI debounce timer on an inbound typing/presence signal - #12

Open
mateusbellozupko wants to merge 6 commits into
evolution-foundation:mainfrom
mateusbellozupko:fix/inbound-typing-debounce-public
Open

mateusbellozupko wants to merge 6 commits into
evolution-foundation:mainfrom
mateusbellozupko:fix/inbound-typing-debounce-public

Conversation

@mateusbellozupko

@mateusbellozupko mateusbellozupko commented Sep 29, 2026 •

Copy link
Copy Markdown

Summary

  • Adds POST /events/presence, a lightweight signal a caller can send when the customer is composing/recording, so an in-flight AI-response debounce timer gets renewed instead of expiring mid-thought.
  • PipelineService.Presence only renews when a debounce window (StageDebounce) is actually active for that pair — it never touches the message buffer, never starts a new debounce window, and never interrupts an in-flight StageAI/StageDispatch stage (a presence pulse alone must never cancel a reply already being generated or sent).
  • Deliberately does not take the per-pair lock Process uses: the operation is an idempotent GetState + SETEX, and losing a lock race against a real message would otherwise risk that message being dropped (the existing lock uses WithTries(1), no retry).
  • DebounceEngine.RenewTimer extends the timer TTL only, sharing the existing Start/Reset timer-setting logic via a small internal helper.

Testing

  • New unit tests for RenewTimer, PresenceEvent.Validate, PipelineService.Presence (renews when debounce active, no-ops when no active debounce, and — using a mock debounce engine plus a dispatch-completion assertion — proves an in-flight AI/dispatch stage is neither renewed against nor cancelled), and the new HTTP handler.
  • Full existing suite passes (go test ./...), including test/e2e.

🤖 Generated with Claude Code

Summary by Sourcery

Extend the pipeline with best-effort presence handling that safely renews only active debounce timers.

New Features:

  • Add an authenticated POST /events/presence endpoint for customer composing or recording signals.
  • Renew active debounce windows without modifying buffered messages or interrupting AI generation and dispatch.

Bug Fixes:

  • Prevent presence signals from recreating expired debounce timers or postponing responses after the pipeline has advanced beyond debounce.

Enhancements:

  • Share debounce timer configuration logic across start, reset, and renewal operations.
  • Refactor inbound event binding and semantic validation into a reusable handler path.

Tests:

  • Add coverage for timer renewal, absent-timer behavior, presence validation and HTTP handling, and protection of in-flight AI and dispatch stages.

Presence competing for the same WithTries(1) lock Process uses risked
silently dropping a real message that lost the race against a purely
cosmetic presence pulse. The operation is idempotent (GetState + SETEX),
so no lock is needed; a race against a stage transition leaves at worst
a stale timer key, already rendered harmless by advanceToAI's own
TimerExists/Stage re-check under its own lock.

TestPresence_NoopDuringAIStage previously drove the pair through a
zero-DebounceTime config, so the real DebounceEngine's own no-op-at-zero
behavior masked whether Presence's StageDebounce guard ran at all —
deleting the guard still passed. Rewritten with a mock debounce engine
(provable renew-call assertion) and a dispatch-completion assertion
proving the in-flight AI/dispatch stage was not cancelled.
@sourcery-ai

sourcery-ai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR adds an authenticated POST /events/presence flow that validates a lightweight presence payload, checks for an existing StageDebounce state, and renews its timer TTL without touching message buffers, acquiring the message-processing lock, or disrupting AI/dispatch execution; shared timer logic and focused unit/handler tests support the behavior.

Sequence diagram for presence-driven debounce renewal

sequenceDiagram
    participant Caller
    participant Handler
    participant PipelineService
    participant PipelineRepository
    participant DebounceEngine
    participant TimerStore

    Caller->>Handler: POST /events/presence
    Handler->>Handler: PresenceEvent.Validate()
    Handler->>PipelineService: Presence(ctx, contactID, conversationID)
    PipelineService->>PipelineRepository: GetState(contactID, conversationID)
    alt StageDebounce is active
        PipelineService->>DebounceEngine: RenewTimer(ctx, contactID, conversationID, botConfig)
        DebounceEngine->>TimerStore: SetTimer(pair, debounce TTL)
        TimerStore-->>DebounceEngine: TTL renewed
        DebounceEngine-->>PipelineService: success
    else No state or stage is not StageDebounce
        PipelineService-->>Handler: no-op
    end
    Handler-->>Caller: 202 Accepted
Loading

Flow diagram for presence signal handling

flowchart TD
    A[POST /events/presence] --> B[bindAndValidate]
    B -->|Invalid payload| C[400 Bad Request]
    B -->|Valid payload| D[PipelineService.Presence]
    D --> E[GetState]
    E --> F{StageDebounce active?}
    F -->|No| G[Return without changes]
    F -->|Yes| H[RenewTimer]
    H --> I[SetTimer with debounce TTL]
    I --> J[202 Accepted]
    G --> J
Loading

File-Level Changes

Change Details Files
Add a presence signal path that renews only an active debounce timer without altering buffered messages or interrupting later pipeline stages.
  • Expose RenewTimer on the debounce engine and share TTL-setting logic across start, reset, and renewal.
  • Add PipelineService.Presence with a state-stage guard and lock-free GetState plus timer renewal.
  • Verify that presence during AI/dispatch is a no-op and does not cancel or interfere with completion.
pkg/debounce/service/debounce_engine.go
pkg/pipeline/service/pipeline_service.go
pkg/pipeline/service/pipeline_service_test.go
pkg/debounce/service/debounce_engine_test.go
Expose a validated, authenticated HTTP endpoint for inbound presence events with best-effort processing.
  • Register POST /events/presence behind the existing secret middleware.
  • Add generic bind-and-validate handling and return 202 even when presence renewal fails.
  • Add positive, invalid-payload, authentication, and failure-response handler coverage.
pkg/pipeline/handler/handler.go
pkg/pipeline/handler/handler_test.go
Introduce a presence-only event model with shared pair-ID validation.
  • Add PresenceEvent containing contact and conversation IDs.
  • Reuse pair-ID validation between message and presence events.
  • Test valid and non-positive identifier cases.
pkg/pipeline/model/pipeline.go
pkg/pipeline/model/presence_event_test.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="pkg/pipeline/service/pipeline_service.go" line_range="295-296" />
<code_context>
+		return nil
+	}
+
+	if err := s.debounce.RenewTimer(ctx, contactID, conversationID, state.BotConfig); err != nil {
+		return fmt.Errorf("pipeline.presence.renew_timer: %w", err)
+	}
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Presence renews the timer whenever Redis still reports `StageDebounce`, even if the debounce timer key has already expired. `RenewTimer` then recreates the expired key, causing `advanceToAI` to see a timer and defer processing, so a presence pulse arriving after expiry can resurrect and indefinitely postpone the reply.

**Triggers:** When the timer expires but the poller has not yet transitioned the state out of `StageDebounce`, and a presence request arrives in that interval.

**Suggested fix:** Renew only an existing timer, using an atomic repository operation that refreshes the TTL only if the timer key still exists; do not implement this as an unconditional `SETEX` after a separate state check.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and if the renewal or presence-state check is wrong, an active conversation could wait longer than intended before AI processing, though no message data is changed or deleted. Reverting stops future renewals, but timers already extended remain until they expire, which is bounded and repairable.

Blocking findings: pkg/pipeline/service/pipeline_service.go:296


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread pkg/pipeline/service/pipeline_service.go
@mateusbellozupko

Copy link
Copy Markdown
Author

Fixed in 7e2482a: added PipelineRepository.RenewTimerIfExists (Redis SET ... XX, only sets if the key already exists) and wired DebounceEngine.RenewTimer through it instead of the plain SetTimer used by Start/Reset. RenewTimer can no longer resurrect an expired/deleted timer key. New regression tests at both the repository and debounce-engine layers.

Sourcery review on evolution-foundation#12: RenewTimer
used an unconditional SET, so a presence pulse landing in the ~100ms
window after pollDebounceExpiry sees the timer key gone (but before it
advances the pair to StageAI) would recreate the key and indefinitely
postpone the reply. Added PipelineRepository.RenewTimerIfExists (Redis
SET ... XX — only sets if the key already exists) and wired
DebounceEngine.RenewTimer through it instead of the plain SetTimer used
by Start/Reset, which legitimately need to create a fresh key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant