Files
kandev/apps/backend/internal/orchestrator/messagequeue/service_test.go
T
João Salavisa 3d2a04e776 feat(orchestrator): deferred task move with hand-off prompt + boot/turn-end event split (#743)
* feat(orchestrator): reuse existing same-profile session on workflow step switch

When a workflow step transition needs a different agent profile, look up
an existing session on the same task that already uses the target profile
and promote it back to primary instead of creating a fresh one. Reuses
COMPLETED and FAILED sessions (CANCELLED stays excluded — explicit user
stops shouldn't be auto-revived).

Backend (event_handlers_workflow.go):
  - findReusableSessionForProfile: most recently updated session matching
    the target profile, skipping CANCELLED.
  - reuseSessionForStep: promotes the session to primary; if terminal,
    revives via reviveReusedSession.
  - reviveReusedSession: distinguishes "previously launched" (has
    executors_running record -> flip to WAITING_FOR_INPUT, lazy-resume via
    ResumeSession with stored token) from "never launched" (no record ->
    flip to CREATED so autoStartStepPrompt routes through
    StartCreatedSession for a full fresh launch). Clears stale
    ErrorMessage/CompletedAt so the UI stops surfacing prior failures.
  - createNewSessionForStep + completeAndStopSession: shared helpers.

Backend (task_operations.go):
  - StartCreatedSession no longer calls processOnTurnStartViaEngine.
    User-initiated messages already fire on_turn_start in
    dispatchPromptAsync. Workflow-driven auto-start prompts arrived
    because the workflow just transitioned us into this step via
    on_turn_complete; firing on_turn_start again cascades the workflow
    back out and the redirect-to-active-session logic delivers the prompt
    to the wrong session's agent.

Frontend (web):
  - task.updated handler follows focus to the new primary when the user
    is viewing the task and was sitting on the previous primary.
  - pinnedSessionId tracks user-initiated session selection
    (setActiveSession sets it; setActiveSessionAuto leaves it alone).
    The terminal-handoff branch in maybeAdoptSessionOnTransition skips
    replacement when the terminating session matches the pin, so manually
    clicking a session no longer yanks focus back to the workflow primary
    each time the pinned session terminates.

Tests:
  - TestSwitchSessionForStep_ReusesExistingProfileSession (never-launched).
  - TestSwitchSessionForStep_ReusesPreviouslyLaunchedSession (launched).
  - TestSwitchSessionForStep_ReusesFailedSession (FAILED reuse + cleanup).
  - tasks.test.ts: focus-follow behavior across 4 scenarios.
  - agent-session.test.ts: pinnedSessionId guard plus migration to
    setActiveSessionAuto for WS-driven adoption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(orchestrator): deferred task move with hand-off prompt + boot/turn-end event split

Adds move_task_kandev MCP tool that lets a workflow agent (e.g. QA at "Review")
move a task to another step with a prompt for the receiving agent. The move is
queued as a PendingMove and applied on the calling agent's turn-end so on_enter
processing doesn't race the still-running turn.

Also splits agent.ready into agent.boot_ready + agent.ready at the lifecycle
layer. Previously both signals shared one event and the orchestrator
disambiguated them via a resumeInProgressSessions flag — which had a race
when the boot signal arrived before persistResumeState wrote state=STARTING,
causing the boot to fire on_turn_complete against the just-revived step and
ping-pong the task forward through the workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR #743 review feedback (Greptile + CodeRabbit)

Backend:
- isTransientPromptError no longer treats ErrExecutionNotFound as transient,
  preventing executeQueuedMessage from infinite-requeueing on permanent
  missing-execution errors. autoStartStepPrompt's explicit errors.Is check
  before the transient branch keeps the recovery path working (Greptile P2 #1)
- applyPendingMove drains the orphaned hand-off prompt on ApplyTransition
  failure so it can't be misdelivered to the source step on a future turn
  (Greptile P2 #2)
- handleMoveTask returns errors instead of panicking when messageQueue is
  nil; queueMoveTaskPrompt returns an error so the deferred path fails fast
  if the queue can't accept the prompt (CodeRabbit CR-2)
- handleMoveTask rejects when no primary session exists and a hand-off prompt
  is required, instead of silently dropping it (CodeRabbit CR-3)
- autoStartStepPrompt preserves attachments from queued messages (was: silently
  dropped); attachment-only messages are no longer skipped; the fallback path
  also forwards attachments (CodeRabbit CR-5)
- applyPendingMove reinserts the pending move on early failure paths (config
  missing, task/step load errors) so a future turn-end retries naturally
  (CodeRabbit CR-6)
- registerKanbanTools split into 3 helpers (list/profile/task) to satisfy
  backend function-size lint limits (CodeRabbit CR-1)

Web:
- maybeAdoptSessionOnTransition's auto-adopt path also checks pinnedSessionId
  to handle the reverse-event-ordering race (old COMPLETED arrives before new
  STARTING) — pinned sessions are no longer yanked off the user. Regression
  test added (CodeRabbit CR-7)

Tests:
- mockAgentManager records (executionID, prompt) pairs; new helper
  capturedPromptsForExecution actually filters by exec ID instead of
  returning all captured prompts (CodeRabbit CR-4)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): respect pinned session in primary-follow-focus path

When the user clicks a session, setActiveSession sets pinnedSessionId. If a
workflow profile switch then updates the task's primarySessionId while the
user happens to still be on the previous primary, the focus-follow block in
tasks.ts was calling setActiveSessionAuto(taskId, newPrimary) and silently
overriding the explicit pin. Add pinnedSessionId !== previousPrimary as a
fifth condition. Regression test in tasks.test.ts covers exactly this
sequence.

Addresses Greptile P1 on PR #743.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address CodeRabbit review on PR #743 (3 new comments)

- handleMoveTask idle path now rolls back the queued hand-off prompt when
  taskSvc.MoveTask fails, so a "you were moved..." message can't be delivered
  on a future turn for a transition that didn't actually happen. Added
  TakeQueued to the MessageQueuer interface to support this.
- lookupSession now returns (*models.TaskSession, error) so callers can
  distinguish a backend lookup failure from a legitimate "no primary session"
  state. handleMoveTask maps the error case to ErrorCodeInternalError instead
  of collapsing it into ErrorCodeValidation.
- queueMoveTaskPrompt returns errors instead of log-and-continue (signature
  change already shipped); existing tests updated to reflect the new contract.
- Refactored TestPendingMove_ReviewToInProgress_OneTransitionOnly: the
  ~280-line monolithic test is now split into a pendingMoveScenario builder
  + assertion helpers, each well under the 80-line / 50-statement lint limit.

All 14 review threads resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: relax move_task_kandev prompt requirement; fix CI lint

CI surfaced two issues after rebasing onto main:

- nestif lint warning on autoStartStepPrompt's queued-message merge block.
  Extracted the take-and-merge logic into takeAndMergeHandoffMessage helper,
  flattening the nested conditionals.
- E2E test settings/config-management 'agent can move a task to a different
  step' failed because my CR-3 fix made the move_task_kandev prompt parameter
  required. That broke admin/config-mode moves where there's no agent to
  hand off to. Made prompt optional: when supplied AND the source session
  is mid-turn, the deferred-move flow runs (queue prompt + record
  PendingMove); otherwise just move the task. Refactored handleMoveTask
  into deferMoveTaskWithPrompt and applyMoveTaskImmediate to keep each
  function under the 80-line lint limit.

Tool descriptions in mcp/server/server.go and mcp/server/config_handlers.go
updated to reflect prompt's now-optional status. moveTaskHandler in
config_handlers.go forwards prompt only when set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(mcp): treat 'no primary session' as a not-found, not an internal error

GetPrimarySessionByTaskID returns a plain error string ("no primary session
found for task: <id>") when the repo has no rows. My CR-B refactor was
treating ALL lookup errors as ErrorCodeInternalError, which broke the E2E
test 'agent can move a task to a different step' (config-mode move on a
freshly-created task that has no agent yet).

Match the not-found message and return (nil, nil); only real backend
failures bubble up as InternalError. Distinguishing properly with
errors.Is would require an exported sentinel that doesn't exist; matching
the noRowsErr substring is the pragmatic fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 18:17:36 +01:00

529 lines
15 KiB
Go

package messagequeue
import (
"context"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupService(t *testing.T) *Service {
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "console",
OutputPath: "stdout",
})
require.NoError(t, err)
return NewService(log)
}
func TestQueueMessage(t *testing.T) {
t.Run("successfully queues a message", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
msg, err := svc.QueueMessage(ctx, "session-1", "task-1", "test content", "model-1", "user-1", false, nil)
require.NoError(t, err)
assert.NotEmpty(t, msg.ID)
assert.Equal(t, "session-1", msg.SessionID)
assert.Equal(t, "task-1", msg.TaskID)
assert.Equal(t, "test content", msg.Content)
assert.Equal(t, "model-1", msg.Model)
assert.Equal(t, "user-1", msg.QueuedBy)
assert.False(t, msg.PlanMode)
assert.NotZero(t, msg.QueuedAt)
})
t.Run("replaces existing queued message for same session", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue first message
msg1, err := svc.QueueMessage(ctx, "session-1", "task-1", "first message", "model-1", "user-1", false, nil)
require.NoError(t, err)
// Queue second message for same session
msg2, err := svc.QueueMessage(ctx, "session-1", "task-1", "second message", "model-1", "user-1", false, nil)
require.NoError(t, err)
// IDs should be different
assert.NotEqual(t, msg1.ID, msg2.ID)
// Only the second message should be queued
status := svc.GetStatus(ctx, "session-1")
assert.True(t, status.IsQueued)
assert.Equal(t, msg2.ID, status.Message.ID)
assert.Equal(t, "second message", status.Message.Content)
})
t.Run("queues messages with attachments", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
attachments := []MessageAttachment{
{Type: "image", Data: "base64data", MimeType: "image/png"},
}
msg, err := svc.QueueMessage(ctx, "session-1", "task-1", "message with attachment", "model-1", "user-1", false, attachments)
require.NoError(t, err)
assert.Len(t, msg.Attachments, 1)
assert.Equal(t, "image", msg.Attachments[0].Type)
})
t.Run("supports plan mode flag", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
msg, err := svc.QueueMessage(ctx, "session-1", "task-1", "plan mode message", "model-1", "user-1", true, nil)
require.NoError(t, err)
assert.True(t, msg.PlanMode)
})
}
func TestTransferSession(t *testing.T) {
t.Run("moves queued message and pending move from old to new session", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
_, err := svc.QueueMessage(ctx, "old-sess", "task-1", "hand-off prompt", "", "mcp-move-task", false, nil)
require.NoError(t, err)
svc.SetPendingMove(ctx, "old-sess", &PendingMove{
TaskID: "task-1",
WorkflowStepID: "step-b",
})
svc.TransferSession(ctx, "old-sess", "new-sess")
// Old session is empty.
_, exists := svc.TakeQueued(ctx, "old-sess")
assert.False(t, exists)
_, exists = svc.TakePendingMove(ctx, "old-sess")
assert.False(t, exists)
// New session has both.
msg, exists := svc.TakeQueued(ctx, "new-sess")
require.True(t, exists)
assert.Equal(t, "hand-off prompt", msg.Content)
assert.Equal(t, "new-sess", msg.SessionID)
move, exists := svc.TakePendingMove(ctx, "new-sess")
require.True(t, exists)
assert.Equal(t, "step-b", move.WorkflowStepID)
})
t.Run("no-op when source session is empty", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
svc.TransferSession(ctx, "empty-sess", "new-sess")
_, exists := svc.TakeQueued(ctx, "new-sess")
assert.False(t, exists)
})
}
func TestPendingMove(t *testing.T) {
t.Run("set then take returns the move and clears it", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
svc.SetPendingMove(ctx, "session-1", &PendingMove{
TaskID: "task-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-2",
Position: 3,
})
got, exists := svc.TakePendingMove(ctx, "session-1")
require.True(t, exists)
assert.Equal(t, "task-1", got.TaskID)
assert.Equal(t, "step-2", got.WorkflowStepID)
assert.Equal(t, 3, got.Position)
assert.NotZero(t, got.QueuedAt)
// Idempotent — second take returns nothing.
_, exists = svc.TakePendingMove(ctx, "session-1")
assert.False(t, exists)
})
t.Run("setting twice replaces the previous move", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
svc.SetPendingMove(ctx, "session-1", &PendingMove{TaskID: "task-1", WorkflowStepID: "step-a"})
svc.SetPendingMove(ctx, "session-1", &PendingMove{TaskID: "task-1", WorkflowStepID: "step-b"})
got, exists := svc.TakePendingMove(ctx, "session-1")
require.True(t, exists)
assert.Equal(t, "step-b", got.WorkflowStepID)
})
}
func TestTakeQueued(t *testing.T) {
t.Run("retrieves and removes queued message", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue a message
original, err := svc.QueueMessage(ctx, "session-1", "task-1", "test content", "model-1", "user-1", false, nil)
require.NoError(t, err)
// Take the message
msg, exists := svc.TakeQueued(ctx, "session-1")
assert.True(t, exists)
assert.Equal(t, original.ID, msg.ID)
assert.Equal(t, "test content", msg.Content)
// Should be removed after taking
status := svc.GetStatus(ctx, "session-1")
assert.False(t, status.IsQueued)
assert.Nil(t, status.Message)
})
t.Run("returns false when no message queued", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
msg, exists := svc.TakeQueued(ctx, "session-1")
assert.False(t, exists)
assert.Nil(t, msg)
})
t.Run("is idempotent - second take returns false", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue and take once
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "test", "model-1", "user-1", false, nil)
require.NoError(t, err)
_, exists := svc.TakeQueued(ctx, "session-1")
require.True(t, exists)
// Second take should return false
_, exists = svc.TakeQueued(ctx, "session-1")
assert.False(t, exists)
})
}
func TestCancelQueued(t *testing.T) {
t.Run("cancels and removes queued message", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue a message
original, err := svc.QueueMessage(ctx, "session-1", "task-1", "test content", "model-1", "user-1", false, nil)
require.NoError(t, err)
// Cancel it
cancelled, err := svc.CancelQueued(ctx, "session-1")
require.NoError(t, err)
assert.Equal(t, original.ID, cancelled.ID)
// Should be removed
status := svc.GetStatus(ctx, "session-1")
assert.False(t, status.IsQueued)
})
t.Run("returns error when no message queued", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
_, err := svc.CancelQueued(ctx, "session-1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no queued message")
})
}
func TestGetStatus(t *testing.T) {
t.Run("returns queued status with message", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
msg, err := svc.QueueMessage(ctx, "session-1", "task-1", "test", "model-1", "user-1", false, nil)
require.NoError(t, err)
status := svc.GetStatus(ctx, "session-1")
assert.True(t, status.IsQueued)
assert.NotNil(t, status.Message)
assert.Equal(t, msg.ID, status.Message.ID)
})
t.Run("returns not queued status when no message", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
status := svc.GetStatus(ctx, "session-1")
assert.False(t, status.IsQueued)
assert.Nil(t, status.Message)
})
}
func TestAppendContent(t *testing.T) {
t.Run("appends content to existing queued message", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "original content", "model-1", "user-1", false, nil)
require.NoError(t, err)
err = svc.AppendContent(ctx, "session-1", "appended content")
require.NoError(t, err)
status := svc.GetStatus(ctx, "session-1")
assert.True(t, status.IsQueued)
assert.Equal(t, "original content\n\n---\n\nappended content", status.Message.Content)
})
t.Run("appends multiple times", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "first", "model-1", "user-1", false, nil)
require.NoError(t, err)
require.NoError(t, svc.AppendContent(ctx, "session-1", "second"))
require.NoError(t, svc.AppendContent(ctx, "session-1", "third"))
status := svc.GetStatus(ctx, "session-1")
assert.Equal(t, "first\n\n---\n\nsecond\n\n---\n\nthird", status.Message.Content)
})
t.Run("returns error when no message queued", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
err := svc.AppendContent(ctx, "session-1", "content")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no queued message")
})
t.Run("preserves message ID after append", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
msg, err := svc.QueueMessage(ctx, "session-1", "task-1", "original", "model-1", "user-1", false, nil)
require.NoError(t, err)
originalID := msg.ID
require.NoError(t, svc.AppendContent(ctx, "session-1", "extra"))
status := svc.GetStatus(ctx, "session-1")
assert.Equal(t, originalID, status.Message.ID)
})
}
func TestUpdateMessage(t *testing.T) {
t.Run("updates existing queued message content", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue a message
msg, err := svc.QueueMessage(ctx, "session-1", "task-1", "original content", "model-1", "user-1", false, nil)
require.NoError(t, err)
originalID := msg.ID
// Update it
err = svc.UpdateMessage(ctx, "session-1", "updated content")
require.NoError(t, err)
// Verify update
status := svc.GetStatus(ctx, "session-1")
assert.True(t, status.IsQueued)
assert.Equal(t, originalID, status.Message.ID)
assert.Equal(t, "updated content", status.Message.Content)
})
t.Run("returns error when no message queued", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
err := svc.UpdateMessage(ctx, "session-1", "new content")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no queued message")
})
t.Run("allows empty content updates", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue a message
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "original", "model-1", "user-1", false, nil)
require.NoError(t, err)
// Update to empty (validation happens at handler level)
err = svc.UpdateMessage(ctx, "session-1", "")
require.NoError(t, err)
status := svc.GetStatus(ctx, "session-1")
assert.Equal(t, "", status.Message.Content)
})
}
func TestConcurrentAccess(t *testing.T) {
t.Run("handles concurrent queue operations safely", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
var wg sync.WaitGroup
numGoroutines := 100
// Concurrently queue messages to different sessions
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sessionID := "session-" + string(rune('A'+idx%26))
_, err := svc.QueueMessage(ctx, sessionID, "task-1", "content", "model-1", "user-1", false, nil)
assert.NoError(t, err)
}(i)
}
wg.Wait()
// Verify all sessions have messages
sessionIDs := make(map[string]bool)
for i := 0; i < numGoroutines; i++ {
sessionID := "session-" + string(rune('A'+i%26))
sessionIDs[sessionID] = true
}
for sessionID := range sessionIDs {
status := svc.GetStatus(ctx, sessionID)
assert.True(t, status.IsQueued, "session %s should be queued", sessionID)
}
})
t.Run("handles concurrent take operations safely", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue a message
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "test", "model-1", "user-1", false, nil)
require.NoError(t, err)
var wg sync.WaitGroup
successCount := 0
var mu sync.Mutex
// Try to take the message from multiple goroutines
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, exists := svc.TakeQueued(ctx, "session-1")
if exists {
mu.Lock()
successCount++
mu.Unlock()
}
}()
}
wg.Wait()
// Only one should succeed
assert.Equal(t, 1, successCount, "only one goroutine should successfully take the message")
// Message should be gone
status := svc.GetStatus(ctx, "session-1")
assert.False(t, status.IsQueued)
})
t.Run("handles concurrent updates and reads safely", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue initial message
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "initial", "model-1", "user-1", false, nil)
require.NoError(t, err)
var wg sync.WaitGroup
numUpdates := 50
numReads := 50
// Concurrent updates
for i := 0; i < numUpdates; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_ = svc.UpdateMessage(ctx, "session-1", "update-"+string(rune('0'+idx%10)))
}(i)
}
// Concurrent reads
for i := 0; i < numReads; i++ {
wg.Add(1)
go func() {
defer wg.Done()
status := svc.GetStatus(ctx, "session-1")
assert.True(t, status.IsQueued)
}()
}
wg.Wait()
// Final state should be consistent
status := svc.GetStatus(ctx, "session-1")
assert.True(t, status.IsQueued)
assert.NotEmpty(t, status.Message.Content)
})
}
func TestMultipleSessionsIsolation(t *testing.T) {
t.Run("sessions are isolated from each other", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
// Queue messages for multiple sessions
_, err := svc.QueueMessage(ctx, "session-1", "task-1", "message-1", "model-1", "user-1", false, nil)
require.NoError(t, err)
_, err = svc.QueueMessage(ctx, "session-2", "task-2", "message-2", "model-1", "user-1", false, nil)
require.NoError(t, err)
// Take from session-1
msg1, exists := svc.TakeQueued(ctx, "session-1")
require.True(t, exists)
assert.Equal(t, "message-1", msg1.Content)
// session-2 should still have its message
status2 := svc.GetStatus(ctx, "session-2")
assert.True(t, status2.IsQueued)
assert.Equal(t, "message-2", status2.Message.Content)
// session-1 should be empty
status1 := svc.GetStatus(ctx, "session-1")
assert.False(t, status1.IsQueued)
})
}
func TestQueuedMessageTimestamp(t *testing.T) {
t.Run("sets queued timestamp correctly", func(t *testing.T) {
svc := setupService(t)
ctx := context.Background()
before := time.Now()
msg, err := svc.QueueMessage(ctx, "session-1", "task-1", "test", "model-1", "user-1", false, nil)
after := time.Now()
require.NoError(t, err)
assert.True(t, msg.QueuedAt.After(before) || msg.QueuedAt.Equal(before))
assert.True(t, msg.QueuedAt.Before(after) || msg.QueuedAt.Equal(after))
})
}