374 lines
12 KiB
Go
374 lines
12 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration tests: unsolicited agent events (background task completion)
|
|
//
|
|
// Covers the end-to-end flow for events that an agent emits AFTER a user's
|
|
// turn has completed — e.g. a Claude Code `run_in_background` bash task
|
|
// finishing minutes later. Without the unsolicited reader, those events
|
|
// pile up in the buffered channel and get discarded by drainEvents() on
|
|
// the next user message.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// persistentEventsSession is an AgentSession with a long-lived events channel
|
|
// that stays open across turns. Unlike FakeAgentSession (which returns a new
|
|
// closed channel per Events() call), this is required to model the real
|
|
// Claude Code behavior where one channel spans multiple turns.
|
|
type persistentEventsSession struct {
|
|
mu sync.Mutex
|
|
sessionID string
|
|
alive bool
|
|
events chan core.Event
|
|
prompts []string
|
|
closed chan struct{}
|
|
}
|
|
|
|
func newPersistentEventsSession(id string) *persistentEventsSession {
|
|
return &persistentEventsSession{
|
|
sessionID: id,
|
|
alive: true,
|
|
events: make(chan core.Event, 64),
|
|
closed: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *persistentEventsSession) Send(prompt string, _ []core.ImageAttachment, _ []core.FileAttachment) error {
|
|
s.mu.Lock()
|
|
s.prompts = append(s.prompts, prompt)
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *persistentEventsSession) RespondPermission(_ string, _ core.PermissionResult) error {
|
|
return nil
|
|
}
|
|
func (s *persistentEventsSession) Events() <-chan core.Event { return s.events }
|
|
func (s *persistentEventsSession) CurrentSessionID() string { return s.sessionID }
|
|
func (s *persistentEventsSession) Alive() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.alive
|
|
}
|
|
func (s *persistentEventsSession) Close() error {
|
|
s.mu.Lock()
|
|
if !s.alive {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
s.alive = false
|
|
close(s.events)
|
|
s.mu.Unlock()
|
|
select {
|
|
case <-s.closed:
|
|
default:
|
|
close(s.closed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// emit pushes an event into the channel.
|
|
func (s *persistentEventsSession) emit(ev core.Event) {
|
|
s.events <- ev
|
|
}
|
|
|
|
// promptCount returns how many Send calls have occurred (indicates how many
|
|
// foreground turns the engine has dispatched).
|
|
func (s *persistentEventsSession) promptCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.prompts)
|
|
}
|
|
|
|
type persistentEventsAgent struct {
|
|
name string
|
|
session *persistentEventsSession
|
|
}
|
|
|
|
func newPersistentEventsAgent(name string, session *persistentEventsSession) *persistentEventsAgent {
|
|
return &persistentEventsAgent{name: name, session: session}
|
|
}
|
|
|
|
func (a *persistentEventsAgent) Name() string { return a.name }
|
|
func (a *persistentEventsAgent) StartSession(_ context.Context, _ string) (core.AgentSession, error) {
|
|
return a.session, nil
|
|
}
|
|
func (a *persistentEventsAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (a *persistentEventsAgent) Stop() error { return nil }
|
|
|
|
// capturingPlatform records all messages sent via Send/Reply and captures
|
|
// the handler passed to Start so tests can inject messages directly.
|
|
type capturingPlatform struct {
|
|
mu sync.Mutex
|
|
sent []string
|
|
handler core.MessageHandler
|
|
started chan struct{}
|
|
}
|
|
|
|
func newCapturingPlatform() *capturingPlatform {
|
|
return &capturingPlatform{started: make(chan struct{})}
|
|
}
|
|
|
|
func (p *capturingPlatform) Name() string { return "capture" }
|
|
func (p *capturingPlatform) Start(h core.MessageHandler) error {
|
|
p.mu.Lock()
|
|
p.handler = h
|
|
p.mu.Unlock()
|
|
select {
|
|
case <-p.started:
|
|
default:
|
|
close(p.started)
|
|
}
|
|
return nil
|
|
}
|
|
func (p *capturingPlatform) Reply(_ context.Context, _ any, c string) error {
|
|
p.mu.Lock()
|
|
p.sent = append(p.sent, c)
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (p *capturingPlatform) Send(_ context.Context, _ any, c string) error {
|
|
p.mu.Lock()
|
|
p.sent = append(p.sent, c)
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (p *capturingPlatform) Stop() error { return nil }
|
|
|
|
func (p *capturingPlatform) messages() []string {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
cp := make([]string, len(p.sent))
|
|
copy(cp, p.sent)
|
|
return cp
|
|
}
|
|
|
|
func (p *capturingPlatform) dispatch(msg *core.Message) {
|
|
p.mu.Lock()
|
|
h := p.handler
|
|
p.mu.Unlock()
|
|
if h == nil {
|
|
panic("platform handler not set — Start must be called first")
|
|
}
|
|
h(p, msg)
|
|
}
|
|
|
|
// waitForMessage polls until a message containing substr is relayed or
|
|
// the timeout expires. Returns true if found. Event-driven, no fixed sleeps.
|
|
func waitForMessage(t *testing.T, p *capturingPlatform, substr string, timeout time.Duration) bool {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
for _, m := range p.messages() {
|
|
if strings.Contains(m, substr) {
|
|
return true
|
|
}
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// waitForPromptCount polls until the session has received at least n prompts
|
|
// (i.e. n foreground turns have been dispatched).
|
|
func waitForPromptCount(t *testing.T, s *persistentEventsSession, n int, timeout time.Duration) bool {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if s.promptCount() >= n {
|
|
return true
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestIntegration_UnsolicitedEventsEndToEnd verifies the full happy-path:
|
|
// 1. User sends msg → agent completes turn → foreground response delivered
|
|
// 2. Agent later emits new events (simulating background task completion)
|
|
// → unsolicited reader relays them to the platform
|
|
// 3. User sends a SECOND msg → unsolicited events are NOT re-delivered and
|
|
// are NOT drained (eventsNeedResync=false after the clean unsolicited
|
|
// turn), and the new foreground response is delivered correctly
|
|
func TestIntegration_UnsolicitedEventsEndToEnd(t *testing.T) {
|
|
sess := newPersistentEventsSession("unsol-e2e")
|
|
agent := newPersistentEventsAgent("fake-claude", sess)
|
|
platform := newCapturingPlatform()
|
|
|
|
engine := core.NewEngine("test", agent, []core.Platform{platform}, "", core.LangEnglish)
|
|
defer engine.Stop()
|
|
require.NoError(t, engine.Start())
|
|
|
|
// Platform.Start must have been called by engine — handler is now available.
|
|
<-platform.started
|
|
|
|
// ─── Phase 1: user sends first message ──────────────────────
|
|
userMsg1 := &core.Message{
|
|
SessionKey: "test:ch1:u1",
|
|
Platform: "capture",
|
|
MessageID: "msg-001",
|
|
UserID: "u1",
|
|
Content: "run 5 campaigns in background",
|
|
ReplyCtx: "rctx-1",
|
|
}
|
|
go platform.dispatch(userMsg1)
|
|
|
|
// Synchronize on the agent receiving the prompt (not on wall-clock time).
|
|
require.True(t, waitForPromptCount(t, sess, 1, 3*time.Second),
|
|
"agent never received the first prompt")
|
|
|
|
// Feed the foreground turn events.
|
|
sess.emit(core.Event{Type: core.EventText, Content: "Submitted, waiting..."})
|
|
sess.emit(core.Event{Type: core.EventResult, Content: "Submitted, waiting..."})
|
|
|
|
require.True(t, waitForMessage(t, platform, "Submitted", 3*time.Second),
|
|
"foreground turn response was not delivered")
|
|
|
|
// ─── Phase 2: simulate background task completion ──────────
|
|
// These events arrive AFTER the foreground turn ended. Under the old
|
|
// behavior they would sit in the buffer until drained by the next msg.
|
|
const bgDoneMarker = "All 5 campaigns created successfully"
|
|
sess.emit(core.Event{Type: core.EventText, Content: bgDoneMarker})
|
|
sess.emit(core.Event{Type: core.EventResult, Content: bgDoneMarker})
|
|
|
|
require.True(t, waitForMessage(t, platform, bgDoneMarker, 3*time.Second),
|
|
"unsolicited event was not relayed to platform")
|
|
|
|
// ─── Phase 3: user sends a SECOND message ──────────────────
|
|
// This exercises the conditional-drain path: eventsNeedResync is false
|
|
// (clean unsolicited turn), so any events here must be attributed to
|
|
// the new turn rather than drained away. Since the channel is empty
|
|
// by now, this is really a smoke test that a follow-up turn still works.
|
|
userMsg2 := &core.Message{
|
|
SessionKey: "test:ch1:u1",
|
|
Platform: "capture",
|
|
MessageID: "msg-002",
|
|
UserID: "u1",
|
|
Content: "thanks, any failures?",
|
|
ReplyCtx: "rctx-2",
|
|
}
|
|
go platform.dispatch(userMsg2)
|
|
|
|
require.True(t, waitForPromptCount(t, sess, 2, 3*time.Second),
|
|
"second foreground turn never dispatched to agent")
|
|
|
|
const secondTurnMarker = "no failures, all clean"
|
|
sess.emit(core.Event{Type: core.EventText, Content: secondTurnMarker})
|
|
sess.emit(core.Event{Type: core.EventResult, Content: secondTurnMarker})
|
|
|
|
require.True(t, waitForMessage(t, platform, secondTurnMarker, 3*time.Second),
|
|
"second foreground turn response was not delivered")
|
|
|
|
// Final sanity: all three distinct messages present in order.
|
|
msgs := platform.messages()
|
|
t.Logf("platform received %d messages: %v", len(msgs), msgs)
|
|
assert.True(t, containsAll(msgs, "Submitted", bgDoneMarker, secondTurnMarker),
|
|
"expected all three messages to be delivered: %v", msgs)
|
|
}
|
|
|
|
// TestIntegration_StaleEventsDrainedAfterAbnormalExit verifies that when a
|
|
// turn ends abnormally (EventError → eventsNeedResync=true), any events that
|
|
// arrive afterward are NOT relayed as unsolicited AND are drained (not
|
|
// mistaken for the response of) when the next user message starts a new turn.
|
|
func TestIntegration_StaleEventsDrainedAfterAbnormalExit(t *testing.T) {
|
|
sess := newPersistentEventsSession("unsol-abnormal")
|
|
agent := newPersistentEventsAgent("fake-claude", sess)
|
|
platform := newCapturingPlatform()
|
|
|
|
engine := core.NewEngine("test", agent, []core.Platform{platform}, "", core.LangEnglish)
|
|
defer engine.Stop()
|
|
require.NoError(t, engine.Start())
|
|
<-platform.started
|
|
|
|
// ─── Phase 1: user message → abnormal exit ──────────────────
|
|
userMsg1 := &core.Message{
|
|
SessionKey: "test:ch1:u1",
|
|
Platform: "capture",
|
|
MessageID: "msg-err",
|
|
UserID: "u1",
|
|
Content: "do something",
|
|
ReplyCtx: "rctx",
|
|
}
|
|
go platform.dispatch(userMsg1)
|
|
|
|
require.True(t, waitForPromptCount(t, sess, 1, 3*time.Second),
|
|
"agent never received the prompt")
|
|
|
|
// Turn exits abnormally — EventError sets eventsNeedResync=true and
|
|
// causes cleanupInteractiveState in the EventError path (if agent is
|
|
// reported dead). Here the agent is still Alive(), so session persists
|
|
// but the flag stays true.
|
|
sess.emit(core.Event{Type: core.EventError, Error: simpleError("turn failed")})
|
|
|
|
require.True(t, waitForMessage(t, platform, "turn failed", 3*time.Second),
|
|
"error message not delivered")
|
|
|
|
// ─── Phase 2: push "leftover" events that would wrongly relay ──
|
|
// Because eventsNeedResync=true after the error, the unsolicited reader
|
|
// should NOT be started. These events should sit in the buffer.
|
|
const leftoverMarker = "LEFTOVER-SHOULD-BE-DRAINED"
|
|
sess.emit(core.Event{Type: core.EventText, Content: leftoverMarker})
|
|
sess.emit(core.Event{Type: core.EventResult, Content: leftoverMarker})
|
|
|
|
// ─── Phase 3: send a NEXT user message ─────────────────────
|
|
// drainEvents() in processInteractiveMessageWith should clear the
|
|
// buffered leftovers BEFORE agent.Send() is called for the new turn.
|
|
userMsg2 := &core.Message{
|
|
SessionKey: "test:ch1:u1",
|
|
Platform: "capture",
|
|
MessageID: "msg-002",
|
|
UserID: "u1",
|
|
Content: "retry please",
|
|
ReplyCtx: "rctx-2",
|
|
}
|
|
go platform.dispatch(userMsg2)
|
|
|
|
require.True(t, waitForPromptCount(t, sess, 2, 3*time.Second),
|
|
"second foreground turn never dispatched to agent")
|
|
|
|
// Feed the new turn's events.
|
|
const retryMarker = "retry succeeded"
|
|
sess.emit(core.Event{Type: core.EventText, Content: retryMarker})
|
|
sess.emit(core.Event{Type: core.EventResult, Content: retryMarker})
|
|
|
|
require.True(t, waitForMessage(t, platform, retryMarker, 3*time.Second),
|
|
"retry turn response not delivered")
|
|
|
|
// ─── Verify: leftovers were NEVER relayed, before or after the retry ──
|
|
for _, m := range platform.messages() {
|
|
if strings.Contains(m, leftoverMarker) {
|
|
t.Fatalf("leftover event from abnormal turn was incorrectly relayed: %v",
|
|
platform.messages())
|
|
}
|
|
}
|
|
}
|
|
|
|
func containsAll(msgs []string, needles ...string) bool {
|
|
joined := strings.Join(msgs, "\n")
|
|
for _, n := range needles {
|
|
if !strings.Contains(joined, n) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type simpleError string
|
|
|
|
func (e simpleError) Error() string { return string(e) }
|