Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
341 lines
11 KiB
Go
341 lines
11 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
func TestControllerCancelCancelsActiveTurnContextWhenAdapterReturnsNoEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := newBlockingExecAdapter()
|
|
controller := NewController([]Adapter{adapter}, nil)
|
|
|
|
started, err := controller.Start(context.Background(), StartInput{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderCodex,
|
|
CWD: "/workspace",
|
|
Title: "Codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
execResult, err := controller.Exec(context.Background(), ExecInput{
|
|
RoomID: started.Session.RoomID,
|
|
AgentSessionID: started.Session.AgentSessionID,
|
|
Content: textPrompt("long prompt"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
adapter.waitForPrompt(t, "long prompt")
|
|
|
|
cancelResult, err := controller.Cancel(context.Background(), rootCancelInput(started.Session.RoomID, started.Session.AgentSessionID, execResult.TurnID, "user_interrupt"))
|
|
if err != nil {
|
|
t.Fatalf("Cancel: %v", err)
|
|
}
|
|
if !cancelResult.Canceled {
|
|
t.Fatalf("Cancel result = %#v, want canceled active turn", cancelResult)
|
|
}
|
|
waitForSessionStatus(t, controller, "room-1", started.Session.AgentSessionID, SessionStatusCanceled)
|
|
}
|
|
|
|
func TestControllerCancelInvokesProviderBeforeCancelingActiveTurnContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := newCancelOrderingAdapter()
|
|
controller := NewController([]Adapter{adapter}, nil)
|
|
started, err := controller.Start(context.Background(), StartInput{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderCodex,
|
|
Title: "Codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
execResult, err := controller.Exec(context.Background(), ExecInput{
|
|
RoomID: started.Session.RoomID,
|
|
AgentSessionID: started.Session.AgentSessionID,
|
|
Content: textPrompt("long prompt"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
select {
|
|
case <-adapter.execStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Exec did not start")
|
|
}
|
|
|
|
result, err := controller.Cancel(context.Background(), rootCancelInput(
|
|
started.Session.RoomID,
|
|
started.Session.AgentSessionID,
|
|
execResult.TurnID,
|
|
"user_interrupt",
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("Cancel: %v", err)
|
|
}
|
|
if !result.Canceled {
|
|
t.Fatalf("Cancel result = %#v, want canceled", result)
|
|
}
|
|
select {
|
|
case observed := <-adapter.providerCancelObservedContextError:
|
|
if observed != nil {
|
|
t.Fatalf("provider Cancel observed Exec context error = %v, want live context", observed)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("provider Cancel was not invoked")
|
|
}
|
|
select {
|
|
case <-adapter.execDone:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Exec context was not canceled after provider Cancel returned")
|
|
}
|
|
}
|
|
|
|
func TestControllerCancelKeepsActiveTurnUntilAdapterFinishes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := newDeferredRemoteCancelAdapter()
|
|
controller := NewController([]Adapter{adapter}, nil)
|
|
started, err := controller.Start(context.Background(), StartInput{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderCodex,
|
|
Title: "Codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
execResult, err := controller.Exec(context.Background(), ExecInput{
|
|
RoomID: "room-1",
|
|
AgentSessionID: started.Session.AgentSessionID,
|
|
Content: textPrompt("first"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Exec first turn: %v", err)
|
|
}
|
|
waitForSessionStatus(t, controller, "room-1", started.Session.AgentSessionID, SessionStatusWorking)
|
|
|
|
cancelResult, err := controller.Cancel(context.Background(), rootCancelInput("room-1", started.Session.AgentSessionID, execResult.TurnID, "user_interrupt"))
|
|
if err != nil {
|
|
t.Fatalf("Cancel: %v", err)
|
|
}
|
|
if !cancelResult.Canceled {
|
|
t.Fatalf("Cancel result = %#v, want active turn cancel", cancelResult)
|
|
}
|
|
if _, err := controller.Exec(context.Background(), ExecInput{
|
|
RoomID: "room-1",
|
|
AgentSessionID: started.Session.AgentSessionID,
|
|
Content: textPrompt("second"),
|
|
}); !errors.Is(err, ErrSessionActiveTurn) {
|
|
t.Fatalf("Exec while remote cancel is still settling error = %v, want %v", err, ErrSessionActiveTurn)
|
|
}
|
|
current, ok := controller.Session("room-1", started.Session.AgentSessionID)
|
|
if !ok {
|
|
t.Fatal("Session returned ok=false after cancel")
|
|
}
|
|
if current.Status != SessionStatusWorking {
|
|
t.Fatalf("session status after cancel = %q, want %q until adapter finishes", current.Status, SessionStatusWorking)
|
|
}
|
|
|
|
close(adapter.releaseExec)
|
|
waitForSessionStatus(t, controller, "room-1", started.Session.AgentSessionID, SessionStatusCanceled)
|
|
}
|
|
|
|
type cancelOrderingAdapter struct {
|
|
mu sync.Mutex
|
|
execContext context.Context
|
|
execStarted chan struct{}
|
|
execDone chan struct{}
|
|
providerCancelObservedContextError chan error
|
|
}
|
|
|
|
func newCancelOrderingAdapter() *cancelOrderingAdapter {
|
|
return &cancelOrderingAdapter{
|
|
execStarted: make(chan struct{}),
|
|
execDone: make(chan struct{}),
|
|
providerCancelObservedContextError: make(chan error, 1),
|
|
}
|
|
}
|
|
|
|
func (*cancelOrderingAdapter) Provider() string { return ProviderCodex }
|
|
|
|
func (*cancelOrderingAdapter) Start(_ context.Context, session Session) ([]activityshared.Event, error) {
|
|
return []activityshared.Event{newSessionActivityEvent(session, EventSessionStarted, SessionStatusReady, nil)}, nil
|
|
}
|
|
|
|
func (*cancelOrderingAdapter) Resume(context.Context, Session) error { return nil }
|
|
|
|
func (*cancelOrderingAdapter) Close(context.Context, Session) error { return nil }
|
|
|
|
func (a *cancelOrderingAdapter) Exec(ctx context.Context, session Session, _ []PromptContentBlock, _ string, turnID string, emit EventSink, _ CommandSnapshotSink) ([]activityshared.Event, error) {
|
|
a.mu.Lock()
|
|
a.execContext = ctx
|
|
a.mu.Unlock()
|
|
close(a.execStarted)
|
|
emit([]activityshared.Event{
|
|
newTurnActivityEvent(session, EventTurnStarted, turnID, SessionStatusWorking, "", "", nil),
|
|
})
|
|
<-ctx.Done()
|
|
close(a.execDone)
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
func (a *cancelOrderingAdapter) Cancel(context.Context, Session, string) ([]activityshared.Event, error) {
|
|
a.mu.Lock()
|
|
execCtx := a.execContext
|
|
a.mu.Unlock()
|
|
var observed error
|
|
if execCtx != nil {
|
|
observed = execCtx.Err()
|
|
}
|
|
a.providerCancelObservedContextError <- observed
|
|
return nil, nil
|
|
}
|
|
|
|
type deferredRemoteCancelAdapter struct {
|
|
releaseExec chan struct{}
|
|
cancelRequested chan struct{}
|
|
cancelRequestMux sync.Once
|
|
}
|
|
|
|
func newDeferredRemoteCancelAdapter() *deferredRemoteCancelAdapter {
|
|
return &deferredRemoteCancelAdapter{
|
|
releaseExec: make(chan struct{}),
|
|
cancelRequested: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (*deferredRemoteCancelAdapter) Provider() string { return ProviderCodex }
|
|
|
|
func (*deferredRemoteCancelAdapter) Start(_ context.Context, session Session) ([]activityshared.Event, error) {
|
|
return []activityshared.Event{newSessionActivityEvent(session, EventSessionStarted, SessionStatusReady, nil)}, nil
|
|
}
|
|
|
|
func (*deferredRemoteCancelAdapter) Resume(context.Context, Session) error { return nil }
|
|
|
|
func (*deferredRemoteCancelAdapter) Close(context.Context, Session) error { return nil }
|
|
|
|
func (a *deferredRemoteCancelAdapter) Exec(_ context.Context, session Session, _ []PromptContentBlock, _ string, turnID string, emit EventSink, _ CommandSnapshotSink) ([]activityshared.Event, error) {
|
|
emit([]activityshared.Event{
|
|
newTurnActivityEvent(session, EventTurnStarted, turnID, SessionStatusWorking, "", "", nil),
|
|
})
|
|
<-a.releaseExec
|
|
select {
|
|
case <-a.cancelRequested:
|
|
return []activityshared.Event{
|
|
newSessionActivityEvent(session, EventSessionCanceled, SessionStatusCanceled, map[string]any{
|
|
"reason": "user_interrupt",
|
|
}),
|
|
newTurnActivityEvent(session, EventTurnCanceled, turnID, SessionStatusCanceled, "", "", map[string]any{
|
|
"reason": "user_interrupt",
|
|
}),
|
|
}, nil
|
|
default:
|
|
return []activityshared.Event{
|
|
newTurnActivityEvent(session, EventTurnCompleted, turnID, SessionStatusReady, "", "", nil),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func (a *deferredRemoteCancelAdapter) Cancel(context.Context, Session, string) ([]activityshared.Event, error) {
|
|
a.cancelRequestMux.Do(func() {
|
|
close(a.cancelRequested)
|
|
})
|
|
return nil, nil
|
|
}
|
|
|
|
func TestControllerExactTurnCancelDoesNotCancelNewerActiveTurn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := &recordingStartAdapter{provider: ProviderCodex}
|
|
controller := NewController([]Adapter{adapter}, &recordingReporter{})
|
|
controller.store(Session{
|
|
RoomID: "room-1", AgentSessionID: "agent-1", Provider: ProviderCodex,
|
|
ProviderSessionID: "prov-1", Status: SessionStatusWorking,
|
|
})
|
|
controller.mu.Lock()
|
|
controller.turns[sessionKey("room-1", "agent-1")] = activeTurn{turnID: "turn-new"}
|
|
controller.mu.Unlock()
|
|
|
|
result, err := controller.Cancel(context.Background(), rootCancelInput("room-1", "agent-1", "turn-old", "user"))
|
|
if err != nil {
|
|
t.Fatalf("Cancel() error = %v", err)
|
|
}
|
|
if result.Canceled {
|
|
t.Fatalf("Cancel() result = %#v, want not canceled", result)
|
|
}
|
|
if adapter.cancelCalls != 0 {
|
|
t.Fatalf("adapter cancel calls = %d, want 0", adapter.cancelCalls)
|
|
}
|
|
active, ok := controller.activeTurn("room-1", "agent-1")
|
|
if !ok || active.turnID != "turn-new" {
|
|
t.Fatalf("active turn after exact cancel = %#v, %v, want turn-new", active, ok)
|
|
}
|
|
}
|
|
|
|
func TestControllerExactTurnCancelHoldsLifecycleLockThroughAdapterCancel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cancelEntered := make(chan struct{}, 1)
|
|
cancelReleased := make(chan struct{})
|
|
adapter := &recordingStartAdapter{
|
|
provider: ProviderCodex, cancelEntered: cancelEntered, cancelReleased: cancelReleased,
|
|
}
|
|
controller := NewController([]Adapter{adapter}, &recordingReporter{})
|
|
controller.store(Session{
|
|
RoomID: "room-1", AgentSessionID: "agent-1", Provider: ProviderCodex,
|
|
ProviderSessionID: "prov-1", Status: SessionStatusWorking,
|
|
})
|
|
controller.mu.Lock()
|
|
controller.turns[sessionKey("room-1", "agent-1")] = activeTurn{turnID: "turn-1"}
|
|
controller.mu.Unlock()
|
|
|
|
cancelDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := controller.Cancel(context.Background(), rootCancelInput("room-1", "agent-1", "turn-1", "user"))
|
|
cancelDone <- err
|
|
}()
|
|
select {
|
|
case <-cancelEntered:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("adapter cancel did not start")
|
|
}
|
|
|
|
secondLockAcquired := make(chan struct{})
|
|
go func() {
|
|
release := controller.acquireLifecycleLock("room-1", "agent-1")
|
|
close(secondLockAcquired)
|
|
release()
|
|
}()
|
|
select {
|
|
case <-secondLockAcquired:
|
|
t.Fatal("session lifecycle lock was released before adapter cancel completed")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(cancelReleased)
|
|
select {
|
|
case err := <-cancelDone:
|
|
if err != nil {
|
|
t.Fatalf("Cancel() error = %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Cancel() did not finish")
|
|
}
|
|
select {
|
|
case <-secondLockAcquired:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("session lifecycle lock was not released after adapter cancel")
|
|
}
|
|
}
|