package websocket import ( "context" "encoding/json" "errors" "testing" "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" githubsvc "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/task/models" ws "github.com/kandev/kandev/pkg/websocket" "github.com/stretchr/testify/require" ) func testLogger() *logger.Logger { log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) return log } type subscriptionRecordingEventBus struct { *bus.MemoryEventBus subjects []string } type recordingSubscription struct{} func (recordingSubscription) Unsubscribe() error { return nil } func (recordingSubscription) IsValid() bool { return true } type queuedTransportSubscription struct { subject string handler bus.EventHandler events []*bus.Event valid bool } func (s *queuedTransportSubscription) Unsubscribe() error { s.valid = false return nil } func (s *queuedTransportSubscription) IsValid() bool { return s.valid } // queuedTransportEventBus models NATS' independent per-subscription queues. // Tests can schedule one subject's subscription ahead of another while each // subscription still preserves FIFO delivery internally. type queuedTransportEventBus struct { *bus.MemoryEventBus subscriptions []*queuedTransportSubscription } func (b *queuedTransportEventBus) Subscribe( subject string, handler bus.EventHandler, ) (bus.Subscription, error) { subscription := &queuedTransportSubscription{subject: subject, handler: handler, valid: true} b.subscriptions = append(b.subscriptions, subscription) return subscription, nil } func (b *queuedTransportEventBus) Publish(_ context.Context, subject string, event *bus.Event) error { event.Subject = subject for _, subscription := range b.subscriptions { if subscription.valid && (subscription.subject == subject || subscription.subject == ">") { subscription.events = append(subscription.events, event) } } return nil } func (b *queuedTransportEventBus) deliverSubjectFirst(ctx context.Context, subject string) error { for _, subscription := range b.subscriptions { if subscription.subject != subject || len(subscription.events) == 0 { continue } event := subscription.events[0] subscription.events = subscription.events[1:] return subscription.handler(ctx, event) } return nil } func (b *queuedTransportEventBus) deliverAll(ctx context.Context) error { for _, subscription := range b.subscriptions { for len(subscription.events) > 0 { event := subscription.events[0] subscription.events = subscription.events[1:] if err := subscription.handler(ctx, event); err != nil { return err } } } return nil } func (b *subscriptionRecordingEventBus) Subscribe(subject string, _ bus.EventHandler) (bus.Subscription, error) { b.subjects = append(b.subjects, subject) return recordingSubscription{}, nil } func TestTaskEventBroadcaster_UsesOrderedWildcardsForRelatedTaskEvents(t *testing.T) { log := testLogger() eventBus := &subscriptionRecordingEventBus{MemoryEventBus: bus.NewMemoryEventBus(log)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() hub := NewHub(nil, log) b := RegisterTaskNotifications(ctx, eventBus, hub, log) wildcardCount := 0 orderedSubjects := map[string]bool{ events.TaskStateChanged: true, events.TaskSessionStateChanged: true, events.MessageAdded: true, events.MessageUpdated: true, events.MessageDeleted: true, } for _, subject := range eventBus.subjects { if subject == ">" { wildcardCount++ } if orderedSubjects[subject] { t.Fatalf("ordered task event subject %q must use the shared wildcard subscription", subject) } } require.Equal(t, 2, wildcardCount, "each ordered event group must use one NATS-style subscription") _ = b } func TestTaskEventBroadcaster_OrdersLifecycleStateNotifications(t *testing.T) { log := testLogger() eventBus := bus.NewMemoryEventBus(log) hub := NewHub(nil, log) ctx, cancel := context.WithCancel(context.Background()) defer cancel() _ = RegisterTaskNotifications(ctx, eventBus, hub, log) workspaceID := "workspace-1" _ = eventBus.Publish(ctx, events.TaskStateChanged, bus.NewEvent( events.TaskStateChanged, "test", map[string]interface{}{"task_id": "task-1", "workspace_id": workspaceID, "state": "IN_PROGRESS"}, )) _ = eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( events.TaskSessionStateChanged, "test", map[string]interface{}{"task_id": "task-1", "session_id": "session-1", "workspace_id": workspaceID, "new_state": "RUNNING"}, )) first := <-hub.broadcast second := <-hub.broadcast if first.Action != ws.ActionTaskStateChanged || second.Action != ws.ActionSessionStateChanged { t.Fatalf("lifecycle notification order = %q, %q; want task state then session state", first.Action, second.Action) } } func TestTaskEventBroadcaster_OrdersTranscriptMutationsAcrossTransportSubjects(t *testing.T) { log := testLogger() eventBus := &queuedTransportEventBus{MemoryEventBus: bus.NewMemoryEventBus(log)} hub := newTestHub(t) client := newTestClient("ordered-transcript") registerTestClient(hub, client) hub.SubscribeToSession(client, "session-1") ctx, cancel := context.WithCancel(context.Background()) defer cancel() _ = RegisterTaskNotifications(ctx, eventBus, hub, log) deleted := bus.NewEvent(events.MessageDeleted, "test", map[string]interface{}{ "session_id": "session-1", "message_id": "abandoned-message", }) added := bus.NewEvent(events.MessageAdded, "test", map[string]interface{}{ "session_id": "session-1", "message_id": "replacement-message", }) require.NoError(t, eventBus.Publish(ctx, events.MessageDeleted, deleted)) require.NoError(t, eventBus.Publish(ctx, events.MessageAdded, added)) // A NATS callback on an independent message.added subscription can run // while message.deleted is delayed. A shared subscription must keep the // replacement queued behind the deletion even under that schedule. require.NoError(t, eventBus.deliverSubjectFirst(ctx, events.MessageAdded)) require.NoError(t, eventBus.deliverAll(ctx)) actions := make([]string, 0, 2) for range 2 { select { case data := <-client.send: var message ws.Message require.NoError(t, json.Unmarshal(data, &message)) actions = append(actions, message.Action) default: t.Fatal("expected two transcript notifications") } } require.Equal(t, []string{ ws.ActionSessionMessageDeleted, ws.ActionSessionMessageAdded, }, actions) } // TestTaskEventBroadcaster_NoDuplicateSubscriptions verifies that // RegisterTaskNotifications creates one subscription per routed subject (with // lifecycle state events intentionally sharing one ordered wildcard). // // The old code had a second subscription system (subscribeEventBusHandlers in // cmd/kandev/helpers.go) that subscribed to the same routed events, causing // duplicate broadcasts. This test counts the broadcaster's internal // subscriptions directly to guard against re-introducing duplicates. func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { log := testLogger() eventBus := bus.NewMemoryEventBus(log) ctx, cancel := context.WithCancel(context.Background()) defer cancel() hub := NewHub(nil, log) go hub.Run(ctx) b := RegisterTaskNotifications(ctx, eventBus, hub, log) // Count how many subscriptions RegisterTaskNotifications creates. If any // event is subscribed twice, this count will increase and the test will fail. // // Update this number when adding or removing event subscriptions in // RegisterTaskNotifications — it is intentionally exact. const wantSubscriptions = 77 if got := len(b.subscriptions); got != wantSubscriptions { t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+ "did an event get subscribed twice?", got, wantSubscriptions) } // Verify no event subject appears more than once by publishing each of // the 4 previously-duplicated events and counting hub broadcasts. // We use a fresh event bus per sub-test so each has exactly // 1 broadcaster subscription + 1 counter subscription. for _, subject := range []string{ events.MessageAdded, events.MessageUpdated, events.TaskSessionStateChanged, events.TaskSessionActivityChanged, events.GitHubTaskPRUpdated, events.GitHubTaskPRDeleted, events.GitLabTaskMRUpdated, events.GitLabTaskMRDeleted, events.TaskPreviewFeedbackChanged, } { subject := subject t.Run(subject, func(t *testing.T) { perEventBus := bus.NewMemoryEventBus(log) perHub := NewHub(nil, log) go perHub.Run(ctx) _ = RegisterTaskNotifications(ctx, perEventBus, perHub, log) var count int _, _ = perEventBus.Subscribe(subject, func(_ context.Context, _ *bus.Event) error { count++ return nil }) data := map[string]interface{}{ "session_id": "s1", "task_id": "t1", } evt := bus.NewEvent(subject, "test", data) _ = perEventBus.Publish(context.Background(), subject, evt) // This verifies that publishing one event reaches our handler exactly // once. Duplicate-subscription detection is handled by the // len(b.subscriptions) guard above; these sub-tests cover event // delivery correctness for the four previously-duplicated subjects. if count != 1 { t.Errorf("counter handler fired %d times, want 1", count) } }) } } // TestTaskEventBroadcaster_PreservesAllFields verifies that event data passes through the // event bus unmodified — the broadcaster receives the same object that was published, // including fields like turn_id, raw_content, and updated_at that the old // subscribeEventBusHandlers used to strip before constructing a new payload manually. func TestTaskEventBroadcaster_PreservesAllFields(t *testing.T) { log := testLogger() eventBus := bus.NewMemoryEventBus(log) ctx, cancel := context.WithCancel(context.Background()) defer cancel() hub := NewHub(nil, log) go hub.Run(ctx) // Subscribe a handler before the broadcaster to capture what data // the event bus delivers — the broadcaster receives the same object. var captured interface{} _, _ = eventBus.Subscribe(events.MessageAdded, func(_ context.Context, ev *bus.Event) error { captured = ev.Data return nil }) _ = RegisterTaskNotifications(ctx, eventBus, hub, log) original := map[string]interface{}{ "session_id": "s1", "task_id": "t1", "message_id": "m1", "content": "hello world", "raw_content": "raw hello world", "turn_id": "turn-abc", "author_type": "agent", "author_id": "claude", "type": "text", "created_at": "2026-04-20T00:00:00Z", "updated_at": "2026-04-20T00:01:00Z", "metadata": map[string]interface{}{"key": "value"}, } evt := bus.NewEvent(events.MessageAdded, "test", original) _ = eventBus.Publish(context.Background(), events.MessageAdded, evt) capturedMap, ok := captured.(map[string]interface{}) if !ok { t.Fatalf("captured data is not map[string]interface{}, got %T", captured) } // Verify fields that the old handler used to strip are still present. for _, field := range []string{"turn_id", "raw_content", "updated_at"} { if _, exists := capturedMap[field]; !exists { t.Errorf("field %q was stripped from event data", field) } } origJSON, _ := json.Marshal(original) capturedJSON, _ := json.Marshal(capturedMap) if string(origJSON) != string(capturedJSON) { t.Errorf("event data was modified\noriginal: %s\ncaptured: %s", origJSON, capturedJSON) } } func TestTaskEventBroadcaster_ProjectsConversationReceiptFromEventData(t *testing.T) { log := testLogger() hub := NewHub(nil, log) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go hub.Run(ctx) client := newTestClient("conversation-client") client.hub = hub client.conversationSubscriptions = map[string]conversationSubscription{ "core-scope": { ScopeID: "core-scope", SessionID: "session-1", ConsumerKind: conversationConsumerCore, Epoch: "epoch-1", }, } registerTestClient(hub, client) broadcaster := &TaskEventBroadcaster{hub: hub, logger: log} receipt := &models.ConversationMutationReceipt{ SessionID: "session-1", BaseRevision: 4, Revision: 5, Complete: true, Operations: []models.ConversationMutationOperation{{ Kind: models.ConversationMutationUpsert, Entity: models.ConversationEntityMessage, ID: "message-1", SessionID: "session-1", AuthorType: string(models.MessageAuthorUser), Message: &models.Message{ID: "message-1", TaskSessionID: "session-1", AuthorType: models.MessageAuthorUser, Content: "prompt"}, }}, } require.NoError(t, broadcaster.broadcastEvent(context.Background(), bus.NewEvent( events.MessageAdded, "task-service", map[string]interface{}{ "session_id": "session-1", "conversation_receipt": receipt, }, ), ws.ActionSessionMessageAdded)) frame := <-client.send var message ws.Message require.NoError(t, json.Unmarshal(frame, &message)) if message.Action != ws.ActionSessionConversationChanged { t.Fatalf("action = %q, want %q", message.Action, ws.ActionSessionConversationChanged) } } func TestTaskEventBroadcasterDoesNotResetConversationOnReceiptlessSessionRemoval(t *testing.T) { hub := newTestHub(t) client := newTestClient("conversation-client") client.hub = hub registerTestClient(hub, client) client.conversationSubscriptions = map[string]conversationSubscription{ "core-scope": { ScopeID: "core-scope", SessionID: "session-1", ConsumerKind: conversationConsumerCore, Epoch: "epoch-1", }, } hub.SubscribeToSession(client, "session-1") broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} msg, err := ws.NewNotification(ws.ActionSessionRemoved, map[string]any{"session_id": "session-1"}) require.NoError(t, err) require.NoError(t, broadcaster.routeBroadcast(ws.ActionSessionRemoved, msg.Payload, "session-1", "", msg)) frame := <-client.send var received ws.Message require.NoError(t, json.Unmarshal(frame, &received)) if received.Action != ws.ActionSessionRemoved { t.Fatalf("first action = %q, want session removal", received.Action) } select { case extra := <-client.send: t.Fatalf("receiptless removal emitted an extra conversation frame: %s", extra) default: } } func TestTaskEventBroadcaster_CancellationIsSessionScoped(t *testing.T) { h := newTestHub(t) first := newTestClient("first") second := newTestClient("second") registerTestClient(h, first) registerTestClient(h, second) h.SubscribeToSession(first, "session-1") h.SubscribeToSession(second, "session-2") msg, err := ws.NewNotification("session.cancellation_changed", map[string]any{ "session_id": "session-1", "cancellation_pending": true, }) require.NoError(t, err) b := &TaskEventBroadcaster{hub: h, logger: testLogger()} require.NoError(t, b.routeBroadcast("session.cancellation_changed", msg.Payload, "session-1", "", msg)) if !clientReceived(first) { t.Fatal("session subscriber did not receive cancellation notification") } if clientReceived(second) { t.Fatal("cancellation notification crossed the session boundary") } } func TestTaskEventBroadcaster_PlanCommentsAreTaskScoped(t *testing.T) { hub := newTestHub(t) first := newTestClient("first") second := newTestClient("second") registerTestClient(hub, first) registerTestClient(hub, second) hub.SubscribeToTask(first, "task-1") hub.SubscribeToTask(second, "task-2") broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} snapshot := &models.TaskPlanCommentSnapshot{TaskID: "task-1", PlanID: "plan-1"} require.NoError(t, broadcaster.broadcastEvent(context.Background(), bus.NewEvent( events.TaskPlanCommentsChanged, "test", snapshot, ), ws.ActionTaskPlanCommentsChanged)) if !clientReceived(first) { t.Fatal("task subscriber did not receive plan comments notification") } if clientReceived(second) { t.Fatal("plan comments notification crossed the task boundary") } } func TestTaskEventBroadcaster_DropsTaskScopedQueueStatusWithoutSession(t *testing.T) { hub := newTestHub(t) hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }}) broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} payload := map[string]any{ "task_id": "task-without-session", "queue_status_scope": "task", } require.NoError(t, broadcaster.broadcastEvent( context.Background(), bus.NewEvent(events.MessageQueueStatusChanged, "test", payload), ws.ActionMessageQueueStatusChanged, )) select { case leaked := <-hub.broadcast: t.Fatalf("task-scoped queue status was globally broadcast: %s", leaked.Action) default: } } func TestTaskEventBroadcaster_DropsUnscopedGitHubCIOptionsWhenAuthIsEnforced(t *testing.T) { hub := newTestHub(t) hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }}) msg, err := ws.NewNotification(ws.ActionGitHubTaskCIOptionsUpdated, map[string]any{ "task_id": "task-without-workspace", }) require.NoError(t, err) broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} require.NoError(t, broadcaster.routeBroadcast( ws.ActionGitHubTaskCIOptionsUpdated, msg.Payload, "", "", msg, )) select { case leaked := <-hub.broadcast: t.Fatalf("unscoped GitHub CI options update was globally broadcast: %s", leaked.Action) default: } } func TestTaskEventBroadcaster_DropsUnscopedQueueStatus(t *testing.T) { hub := newTestHub(t) hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }}) msg, err := ws.NewNotification(ws.ActionMessageQueueStatusChanged, map[string]any{ "task_id": "task-without-session", }) require.NoError(t, err) broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} require.NoError(t, broadcaster.routeBroadcast( ws.ActionMessageQueueStatusChanged, msg.Payload, "", "", msg, )) select { case leaked := <-hub.broadcast: t.Fatalf("unscoped queue status was globally broadcast: %s", leaked.Action) default: } } func TestTaskEventBroadcaster_ScopesTypedGitHubTaskPRUpdateToOwningWorkspace(t *testing.T) { hub := newTestHub(t) hub.setAuthPolicy(AuthPolicy{ Enforced: func() bool { return true }, WorkspaceOwner: func(_ context.Context, workspaceID string) (string, error) { if workspaceID == "workspace-a" { return "user-a", nil } return "", errors.New("unknown workspace") }, }) clientA := newTestClient("client-a") clientA.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember} clientB := newTestClient("client-b") clientB.identity = authn.Identity{UserID: "user-b", Role: authn.RoleMember} registerTestClient(hub, clientA) registerTestClient(hub, clientB) payload := &githubsvc.TaskPR{ ID: "association-a", WorkspaceID: "workspace-a", TaskID: "task-a", } broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} msg := bus.NewEvent(events.GitHubTaskPRUpdated, "test", payload) require.NoError(t, broadcaster.broadcastEvent(context.Background(), msg, ws.ActionGitHubTaskPRUpdated)) if !clientReceived(clientA) { t.Fatal("owner of workspace-a did not receive task PR update") } if clientReceived(clientB) { t.Fatal("task PR update crossed the workspace boundary") } select { case leaked := <-hub.broadcast: t.Fatalf("task PR update used the global broadcast path: %s", leaked.Action) default: } } func TestTaskEventBroadcaster_DropsUnscopedGitHubTaskPRUpdateWhenAuthIsEnforced(t *testing.T) { hub := newTestHub(t) hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }}) broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} payload := &githubsvc.TaskPR{TaskID: "task-without-workspace"} require.NoError(t, broadcaster.broadcastEvent( context.Background(), bus.NewEvent(events.GitHubTaskPRUpdated, "test", payload), ws.ActionGitHubTaskPRUpdated, )) select { case leaked := <-hub.broadcast: t.Fatalf("unscoped task PR update was globally broadcast: %s", leaked.Action) default: } } func TestTaskEventBroadcaster_ScopesGitHubCIOptionsToOwningWorkspace(t *testing.T) { hub := newTestHub(t) hub.setAuthPolicy(AuthPolicy{ Enforced: func() bool { return true }, WorkspaceOwner: func(_ context.Context, workspaceID string) (string, error) { switch workspaceID { case "workspace-a": return "user-a", nil case "workspace-b": return "user-b", nil default: return "", errors.New("unknown workspace") } }, }) clientA := newTestClient("client-a") clientA.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember} clientB := newTestClient("client-b") clientB.identity = authn.Identity{UserID: "user-b", Role: authn.RoleMember} registerTestClient(hub, clientA) registerTestClient(hub, clientB) broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} for _, test := range []struct { workspaceID string owner *Client foreign *Client }{ {workspaceID: "workspace-a", owner: clientA, foreign: clientB}, {workspaceID: "workspace-b", owner: clientB, foreign: clientA}, } { payload := &githubsvc.TaskCIOptionsResponse{TaskID: "task-" + test.workspaceID, WorkspaceID: test.workspaceID} msg, err := ws.NewNotification(ws.ActionGitHubTaskCIOptionsUpdated, payload) require.NoError(t, err) require.NoError(t, broadcaster.routeBroadcast( ws.ActionGitHubTaskCIOptionsUpdated, payload, "", extractWorkspaceID(payload), msg, )) if !clientReceived(test.owner) { t.Fatalf("owner of %s did not receive CI options update", test.workspaceID) } if clientReceived(test.foreign) { t.Fatalf("CI options update for %s crossed workspace boundary", test.workspaceID) } } } func TestTaskEventBroadcaster_CancellationSubscriptionIsSessionScoped(t *testing.T) { log := testLogger() eventBus := bus.NewMemoryEventBus(log) hub := newTestHub(t) first := newTestClient("first") second := newTestClient("second") registerTestClient(hub, first) registerTestClient(hub, second) hub.SubscribeToSession(first, "session-1") hub.SubscribeToSession(second, "session-2") ctx, cancel := context.WithCancel(context.Background()) defer cancel() b := RegisterTaskNotifications(ctx, eventBus, hub, log) t.Cleanup(b.Close) require.NoError(t, eventBus.Publish(ctx, events.TaskSessionCancellationChanged, bus.NewEvent( events.TaskSessionCancellationChanged, "test", map[string]any{"session_id": "session-1", "cancellation_pending": true}, ))) var notification ws.Message received := <-first.send require.NoError(t, json.Unmarshal(received, ¬ification)) require.Equal(t, ws.ActionSessionCancellationChanged, notification.Action) if clientReceived(second) { t.Fatal("cancellation subscription crossed the session boundary") } } func TestTaskEventBroadcaster_DropsUnscopedSessionPendingActionWhenAuthIsEnforced(t *testing.T) { hub := newTestHub(t) hub.setAuthPolicy(AuthPolicy{Enforced: func() bool { return true }}) client := newTestClient("client") client.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember} registerTestClient(hub, client) msg, err := ws.NewNotification("session.pending_action_changed", map[string]any{ "task_id": "task-without-workspace", "session_id": "session-1", "pending_action": "clarification", "pending_action_revision": map[string]any{ "epoch": "epoch-1", "sequence": 1, }, }) require.NoError(t, err) broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} require.NoError(t, broadcaster.routeBroadcast( "session.pending_action_changed", msg.Payload, "session-1", "", msg, )) if clientReceived(client) { t.Fatal("unscoped session pending-action event was globally broadcast") } } func TestTaskEventBroadcaster_ScopesSessionPendingActionToOwningWorkspace(t *testing.T) { hub := newTestHub(t) hub.setAuthPolicy(AuthPolicy{ Enforced: func() bool { return true }, WorkspaceOwner: func(_ context.Context, workspaceID string) (string, error) { if workspaceID == "workspace-a" { return "user-a", nil } return "", errors.New("unknown workspace") }, }) owner := newTestClient("owner") owner.identity = authn.Identity{UserID: "user-a", Role: authn.RoleMember} foreign := newTestClient("foreign") foreign.identity = authn.Identity{UserID: "user-b", Role: authn.RoleMember} registerTestClient(hub, owner) registerTestClient(hub, foreign) msg, err := ws.NewNotification(ws.ActionSessionPendingActionChanged, map[string]any{ "workspace_id": "workspace-a", "task_id": "task-a", "session_id": "session-a", "pending_action": nil, "pending_action_revision": map[string]any{"epoch": "1", "sequence": 2}, }) require.NoError(t, err) broadcaster := &TaskEventBroadcaster{hub: hub, logger: testLogger()} require.NoError(t, broadcaster.routeBroadcast( ws.ActionSessionPendingActionChanged, msg.Payload, "session-a", "workspace-a", msg, )) if !clientReceived(owner) { t.Fatal("workspace owner did not receive session pending-action event") } if clientReceived(foreign) { t.Fatal("session pending-action event crossed the workspace boundary") } }