3d2a04e776
* 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>
529 lines
15 KiB
Go
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))
|
|
})
|
|
}
|