Files
humanlayer/hld/bus/events_test.go
Sundeep Malladi fd6739dffe Suppress approval notifications for already-approved messages (and some other stuff) (#391)
* Fix auto-accept edits toggle to update store directly

- Update SessionDetail shift+tab handler to directly update store
- Ensures immediate UI feedback when toggling auto-accept mode
- Add debug logging to track toggle behavior

* Add SessionSettingsChanged event for real-time settings updates

Implements a new event type to propagate session settings changes (like auto-accept edits)
from backend to frontend via SSE. This ensures the UI stays in sync when settings are
modified through any interface (REST API or RPC).

Key changes:
- Add EventSessionSettingsChanged constant to event bus types
- Add UpdateSessionSettings method to SessionManager interface to centralize event publishing
- Update REST API handler to use session manager instead of direct store updates
- Fix SSE handler to recognize and forward session_settings_changed events
- Add TypeScript types and event handling in frontend
- Wire up event handler in Layout.tsx to update local state
- Add make task for regenerating SDKs from OpenAPI specs

The implementation follows the existing architectural pattern where managers (not handlers
or stores) are responsible for publishing events to maintain consistency.

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* Add approval notifications to Layout component

Implements proper notification handling for approval events in the main Layout component.
This ensures users are notified when AI agents request approval for tool usage.

Key changes:
- Add notification tracking with isItemNotified/addNotifiedItem to prevent duplicate notifications
- Implement onNewApproval handler to show notifications when approvals are requested
- Move session fetching outside completion block for better code reuse
- Add proper TypeScript types for event data
- Show tool-specific approval messages with fallback for error cases

The implementation follows the event-based notification pattern, as the investigation
showed that status-based notifications (waiting_input transitions) never occur in practice.

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* Add delay to prevent notifications for auto-approved items

Implements a 100ms delay in the onNewApproval handler to check if an approval
was auto-resolved before showing a notification. This prevents the brief flash
of approval notifications for edit operations when auto-accept mode is enabled.

The solution works by:
- Waiting 100ms after receiving a new_approval event
- Checking if the session is still in waiting_input status
- Skipping the notification if the status has already changed (indicating auto-approval)

This handles the race condition where the backend sends both new_approval and
approval_resolved events in quick succession for auto-approved items.

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* Fix auto-approval notification logic with approval cache

Replaces the flawed session status check with a proper approval resolution cache.
The previous approach incorrectly skipped notifications for manual approvals (like Bash)
because it checked session status before updating it to waiting_input.

The new implementation:
- Tracks resolved approvals in a cache when onApprovalResolved fires
- Checks this cache after 100ms delay to determine if an approval was auto-resolved
- Works correctly for both auto-approved (Edit tools) and manual approval cases

Also adds timestamps to console logs for better debugging of event timing.

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* feat(wui): rework shift+tab to toggle auto-accept edits for selected sessions

- Add bulkSetAutoAcceptEdits method to store for bulk updating auto-accept settings
- Replace shift+tab view mode toggle with auto-accept toggle functionality
- Similar behavior to 'e' archive hotkey: works on selected sessions or focused session
- Shows success/error toast notifications for user feedback
- Intelligently toggles status: if all selected sessions have auto-accept enabled, disables it; otherwise enables it

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* feat(wui): improve archive hotkey behavior and add auto-accept indicator

- Fix archive hotkey ('e') to properly handle mixed selection states
- Show warning when trying to bulk archive sessions with different archived states
- Add visual indicator (⏵⏵) for running sessions with auto-accept edits enabled
- Improve error handling and logging for archive operations

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* MCT

* test tweaks

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 15:51:52 -05:00

291 lines
6.6 KiB
Go

package bus
import (
"context"
"sync"
"testing"
"time"
)
func TestEventBus_Subscribe(t *testing.T) {
eb := NewEventBus()
ctx := context.Background()
// Test basic subscription
sub := eb.Subscribe(ctx, EventFilter{})
if sub == nil {
t.Fatal("expected subscriber, got nil")
return // this return exists purely to satisfy the linter
}
if sub.ID == "" {
t.Error("expected subscriber ID, got empty string")
return // this return exists purely to satisfy the linter
}
if sub.Channel == nil {
t.Error("expected channel, got nil")
return // this return exists purely to satisfy the linter
}
// Verify subscriber count
if count := eb.GetSubscriberCount(); count != 1 {
t.Errorf("expected 1 subscriber, got %d", count)
}
}
func TestEventBus_Unsubscribe(t *testing.T) {
eb := NewEventBus()
ctx := context.Background()
sub := eb.Subscribe(ctx, EventFilter{})
initialCount := eb.GetSubscriberCount()
eb.Unsubscribe(sub.ID)
// Verify subscriber was removed
if count := eb.GetSubscriberCount(); count != initialCount-1 {
t.Errorf("expected %d subscribers after unsubscribe, got %d", initialCount-1, count)
}
// Verify channel is closed
select {
case _, ok := <-sub.Channel:
if ok {
t.Error("expected channel to be closed")
}
default:
t.Error("expected channel to be closed")
}
}
func TestEventBus_Publish(t *testing.T) {
eb := NewEventBus()
ctx := context.Background()
// Create subscriber
sub := eb.Subscribe(ctx, EventFilter{})
// Publish event
event := Event{
Type: EventNewApproval,
Data: map[string]interface{}{
"approval_count": 1,
},
}
eb.Publish(event)
// Verify event received
select {
case received := <-sub.Channel:
if received.Type != event.Type {
t.Errorf("expected event type %s, got %s", event.Type, received.Type)
}
if count, ok := received.Data["approval_count"].(int); !ok || count != 1 {
t.Error("expected approval_count=1 in event data")
}
if received.Timestamp.IsZero() {
t.Error("expected timestamp to be set")
}
case <-time.After(100 * time.Millisecond):
t.Error("timeout waiting for event")
}
}
func TestEventBus_EventTypeFilter(t *testing.T) {
eb := NewEventBus()
ctx := context.Background()
// Subscribe only to approval events
sub := eb.Subscribe(ctx, EventFilter{
Types: []EventType{EventNewApproval, EventApprovalResolved},
})
// Publish different event types
eb.Publish(Event{Type: EventNewApproval})
eb.Publish(Event{Type: EventSessionStatusChanged})
eb.Publish(Event{Type: EventApprovalResolved})
// Should receive only approval events
received := 0
timeout := time.After(100 * time.Millisecond)
for {
select {
case event := <-sub.Channel:
received++
if event.Type != EventNewApproval && event.Type != EventApprovalResolved {
t.Errorf("received unexpected event type: %s", event.Type)
}
case <-timeout:
goto done
}
}
done:
if received != 2 {
t.Errorf("expected 2 events, received %d", received)
}
}
func TestEventBus_SessionFilter(t *testing.T) {
eb := NewEventBus()
ctx := context.Background()
// Subscribe to specific session
sub := eb.Subscribe(ctx, EventFilter{
SessionID: "session-123",
})
// Publish events for different sessions
eb.Publish(Event{
Type: EventSessionStatusChanged,
Data: map[string]interface{}{"session_id": "session-123"},
})
eb.Publish(Event{
Type: EventSessionStatusChanged,
Data: map[string]interface{}{"session_id": "session-456"},
})
eb.Publish(Event{
Type: EventSessionStatusChanged,
Data: map[string]interface{}{"session_id": "session-123"},
})
// Should receive only events for session-123
received := 0
timeout := time.After(100 * time.Millisecond)
for {
select {
case event := <-sub.Channel:
received++
if sessionID, ok := event.Data["session_id"].(string); !ok || sessionID != "session-123" {
t.Errorf("received event for wrong session: %v", event.Data["session_id"])
}
case <-timeout:
goto done
}
}
done:
if received != 2 {
t.Errorf("expected 2 events, received %d", received)
}
}
func TestEventBus_ConcurrentPublishSubscribe(t *testing.T) {
eb := NewEventBus()
ctx := context.Background()
// Create multiple subscribers
numSubscribers := 10
subscribers := make([]*Subscriber, numSubscribers)
for i := 0; i < numSubscribers; i++ {
subscribers[i] = eb.Subscribe(ctx, EventFilter{})
}
// Publish events concurrently
numEvents := 100
var wg sync.WaitGroup
wg.Add(numEvents)
for i := 0; i < numEvents; i++ {
go func(n int) {
defer wg.Done()
eb.Publish(Event{
Type: EventNewApproval,
Data: map[string]interface{}{"event_num": n},
})
}(i)
}
wg.Wait()
// Give a small delay for events to propagate
time.Sleep(100 * time.Millisecond)
// Verify each subscriber received all events
for i, sub := range subscribers {
received := 0
// Drain the channel
for {
select {
case <-sub.Channel:
received++
default:
// No more events
goto checkCount
}
}
checkCount:
if received != numEvents {
t.Errorf("subscriber %d: expected %d events, received %d", i, numEvents, received)
}
}
}
func TestEventBus_SlowSubscriber(t *testing.T) {
eb := NewEventBus().(*eventBus) // Need concrete type to check buffer size
eb.bufferSize = 5 // Small buffer for testing
ctx := context.Background()
// Create a slow subscriber that doesn't read events
sub := eb.Subscribe(ctx, EventFilter{})
// Publish more events than buffer size
for i := 0; i < 10; i++ {
eb.Publish(Event{
Type: EventNewApproval,
Data: map[string]interface{}{"num": i},
})
}
// Now read events - should only get buffer size worth
received := 0
timeout := time.After(100 * time.Millisecond)
for {
select {
case <-sub.Channel:
received++
case <-timeout:
goto done
}
}
done:
// Should have received only up to buffer size
if received > eb.bufferSize {
t.Errorf("expected at most %d events, received %d", eb.bufferSize, received)
}
}
func TestEventBus_ContextCancellation(t *testing.T) {
eb := NewEventBus()
ctx, cancel := context.WithCancel(context.Background())
sub := eb.Subscribe(ctx, EventFilter{})
initialCount := eb.GetSubscriberCount()
// Cancel context
cancel()
// Give cleanup goroutine time to run
time.Sleep(50 * time.Millisecond)
// Verify subscriber was removed
if count := eb.GetSubscriberCount(); count != initialCount-1 {
t.Errorf("expected %d subscribers after context cancel, got %d", initialCount-1, count)
}
// Verify channel is closed
select {
case _, ok := <-sub.Channel:
if ok {
t.Error("expected channel to be closed after context cancel")
}
default:
// Channel might be empty but should be closed
}
}