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
509 lines
15 KiB
Go
509 lines
15 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
func TestControllerSessionUpdateDuringActiveTurnDoesNotExposeReady(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := newBlockingSessionUpdateAdapter()
|
|
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("hello"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
|
|
close(adapter.readyToEmit)
|
|
select {
|
|
case <-adapter.emitted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("session update was not emitted")
|
|
}
|
|
waitForCondition(t, func() bool {
|
|
updated, ok := controller.get("room-1", started.Session.AgentSessionID)
|
|
return ok && updated.Title == "Provider title" && updated.Status == SessionStatusWorking
|
|
})
|
|
|
|
cancelResult, err := controller.Cancel(context.Background(), rootCancelInput("room-1", started.Session.AgentSessionID, execResult.TurnID, "user"))
|
|
if err != nil {
|
|
t.Fatalf("Cancel: %v", err)
|
|
}
|
|
if !cancelResult.Canceled {
|
|
t.Fatalf("Cancel result = %#v, want active turn cancel", cancelResult)
|
|
}
|
|
}
|
|
|
|
func TestControllerSessionEventSinkTracksSyntheticTurnLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reporter := &recordingReporter{}
|
|
controller := NewController(nil, reporter)
|
|
session := Session{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderClaudeCode,
|
|
Status: SessionStatusReady,
|
|
}
|
|
controller.store(session)
|
|
|
|
controller.applySessionEventsByAgentSessionID("agent-session-1", []activityshared.Event{
|
|
newTurnActivityEvent(session, EventTurnStarted, "synthetic-turn-1", SessionStatusWorking, "", "", map[string]any{
|
|
"synthetic": true,
|
|
}),
|
|
})
|
|
|
|
stored, ok := controller.get("room-1", "agent-session-1")
|
|
if !ok {
|
|
t.Fatal("stored session missing")
|
|
}
|
|
if stored.Status != SessionStatusWorking {
|
|
t.Fatalf("session status = %q, want working", stored.Status)
|
|
}
|
|
if stored.TurnLifecycle == nil ||
|
|
stored.TurnLifecycle.ActiveTurnID == nil ||
|
|
*stored.TurnLifecycle.ActiveTurnID != "synthetic-turn-1" ||
|
|
stored.TurnLifecycle.Phase != "running" {
|
|
t.Fatalf("turn lifecycle = %#v, want synthetic running", stored.TurnLifecycle)
|
|
}
|
|
if stored.SubmitAvailability == nil ||
|
|
stored.SubmitAvailability.State != "blocked" ||
|
|
stored.SubmitAvailability.Reason != "active_turn" {
|
|
t.Fatalf("submit availability = %#v, want active_turn blocked", stored.SubmitAvailability)
|
|
}
|
|
|
|
reports := reporter.waitForCalls(t, 1)
|
|
patches := reports[len(reports)-1].report.StatePatches
|
|
if len(patches) == 0 ||
|
|
patches[0].TurnLifecycle == nil ||
|
|
patches[0].TurnLifecycle.ActiveTurnID == nil ||
|
|
*patches[0].TurnLifecycle.ActiveTurnID != "synthetic-turn-1" {
|
|
t.Fatalf("reported state patches = %#v, want synthetic turn lifecycle", patches)
|
|
}
|
|
}
|
|
|
|
func TestControllerSessionEventSinkCommitsChildTerminalBeforePublish(t *testing.T) {
|
|
reporter := newSessionEventTerminalBarrierReporter()
|
|
controller := NewController(nil, reporter)
|
|
session := Session{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "root-session-1",
|
|
Provider: ProviderCodex,
|
|
Status: SessionStatusReady,
|
|
}
|
|
controller.store(session)
|
|
childSession := Session{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: "child-session-1",
|
|
Provider: session.Provider,
|
|
Status: SessionStatusReady,
|
|
}
|
|
controller.store(childSession)
|
|
stream, unsubscribe, ok := controller.Subscribe(session.RoomID, session.AgentSessionID)
|
|
if !ok {
|
|
t.Fatal("Subscribe returned ok=false")
|
|
}
|
|
defer unsubscribe()
|
|
select {
|
|
case <-stream:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("initial session snapshot was not published")
|
|
}
|
|
childStream, childUnsubscribe, ok := controller.Subscribe(childSession.RoomID, childSession.AgentSessionID)
|
|
if !ok {
|
|
t.Fatal("child Subscribe returned ok=false")
|
|
}
|
|
defer childUnsubscribe()
|
|
select {
|
|
case <-childStream:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("initial child session snapshot was not published")
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
controller.applySessionEventsByAgentSessionID(
|
|
session.AgentSessionID,
|
|
[]activityshared.Event{lateChildCompletionEvent(session)},
|
|
)
|
|
close(done)
|
|
}()
|
|
|
|
var report agentsessionstore.ReportActivityInput
|
|
select {
|
|
case report = <-reporter.started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("terminal report did not reach the durable barrier")
|
|
}
|
|
if !reportContainsAgentSessionStatePatch(report, "child-session-1") {
|
|
t.Fatalf("terminal report = %#v, want child state patch", report)
|
|
}
|
|
expectNoStreamEventType(t, stream, StreamEventStatePatch)
|
|
|
|
reporter.release <- nil
|
|
waitForPublishedSessionEvent(t, childStream, EventTurnCompleted, "", "")
|
|
expectNoStreamEventType(t, stream, StreamEventStatePatch)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("session event sink did not return after durable commit")
|
|
}
|
|
}
|
|
|
|
func TestControllerSessionEventSinkWithholdsChildTerminalWhenCommitFails(t *testing.T) {
|
|
reporter := newSessionEventTerminalBarrierReporter()
|
|
controller := NewController(nil, reporter)
|
|
session := Session{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "root-session-1",
|
|
Provider: ProviderCodex,
|
|
Status: SessionStatusReady,
|
|
}
|
|
controller.store(session)
|
|
stream, unsubscribe, ok := controller.Subscribe(session.RoomID, session.AgentSessionID)
|
|
if !ok {
|
|
t.Fatal("Subscribe returned ok=false")
|
|
}
|
|
defer unsubscribe()
|
|
select {
|
|
case <-stream:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("initial session snapshot was not published")
|
|
}
|
|
|
|
reporter.release <- errors.New("durable report failed")
|
|
controller.applySessionEventsByAgentSessionID(
|
|
session.AgentSessionID,
|
|
[]activityshared.Event{lateChildCompletionEvent(session)},
|
|
)
|
|
select {
|
|
case <-reporter.started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("terminal report did not reach the durable barrier")
|
|
}
|
|
expectNoStreamEventType(t, stream, StreamEventStatePatch)
|
|
}
|
|
|
|
type sessionEventTerminalBarrierReporter struct {
|
|
started chan agentsessionstore.ReportActivityInput
|
|
release chan error
|
|
}
|
|
|
|
func newSessionEventTerminalBarrierReporter() *sessionEventTerminalBarrierReporter {
|
|
return &sessionEventTerminalBarrierReporter{
|
|
started: make(chan agentsessionstore.ReportActivityInput, 1),
|
|
release: make(chan error, 1),
|
|
}
|
|
}
|
|
|
|
func (r *sessionEventTerminalBarrierReporter) Report(
|
|
ctx context.Context,
|
|
report agentsessionstore.ReportActivityInput,
|
|
) error {
|
|
select {
|
|
case r.started <- report:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
select {
|
|
case err := <-r.release:
|
|
return err
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (r *sessionEventTerminalBarrierReporter) ReportSubmitProvenance(
|
|
ctx context.Context,
|
|
report agentsessionstore.ReportActivityInput,
|
|
) error {
|
|
return r.Report(ctx, report)
|
|
}
|
|
|
|
func lateChildCompletionEvent(root Session) activityshared.Event {
|
|
event := newTurnActivityEvent(
|
|
root,
|
|
EventTurnCompleted,
|
|
"child-turn-1",
|
|
SessionStatusReady,
|
|
"",
|
|
"",
|
|
nil,
|
|
)
|
|
event.AgentSessionID = "child-session-1"
|
|
event.SessionKind = "child"
|
|
event.RootAgentSessionID = root.AgentSessionID
|
|
event.RootTurnID = "root-turn-1"
|
|
return event
|
|
}
|
|
|
|
func reportContainsAgentSessionStatePatch(
|
|
report agentsessionstore.ReportActivityInput,
|
|
agentSessionID string,
|
|
) bool {
|
|
for _, patch := range report.StatePatches {
|
|
if patch.AgentSessionID == agentSessionID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestControllerFinishParentTurnDoesNotOverwriteSyntheticLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
controller := NewController(nil, nil)
|
|
parentTurnID := "parent-turn-1"
|
|
syntheticTurnID := "synthetic-turn-1"
|
|
session := Session{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderClaudeCode,
|
|
Status: SessionStatusWorking,
|
|
TurnLifecycle: &TurnLifecycle{
|
|
ActiveTurnID: &syntheticTurnID,
|
|
Phase: "running",
|
|
},
|
|
SubmitAvailability: blockedSubmitAvailability("active_turn"),
|
|
}
|
|
controller.store(session)
|
|
controller.mu.Lock()
|
|
controller.turns[sessionKey("room-1", "agent-session-1")] = activeTurn{turnID: parentTurnID}
|
|
controller.mu.Unlock()
|
|
|
|
outcome := "completed"
|
|
controller.finishTurn(Session{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderClaudeCode,
|
|
Status: SessionStatusReady,
|
|
TurnLifecycle: &TurnLifecycle{
|
|
Phase: "settled",
|
|
Outcome: &outcome,
|
|
},
|
|
SubmitAvailability: availableSubmitAvailability(),
|
|
}, parentTurnID)
|
|
|
|
stored, ok := controller.get("room-1", "agent-session-1")
|
|
if !ok {
|
|
t.Fatal("stored session missing")
|
|
}
|
|
if stored.TurnLifecycle == nil ||
|
|
stored.TurnLifecycle.ActiveTurnID == nil ||
|
|
*stored.TurnLifecycle.ActiveTurnID != syntheticTurnID ||
|
|
stored.TurnLifecycle.Phase != "running" {
|
|
t.Fatalf("turn lifecycle = %#v, want synthetic running preserved", stored.TurnLifecycle)
|
|
}
|
|
if stored.Status != SessionStatusWorking {
|
|
t.Fatalf("session status = %q, want working", stored.Status)
|
|
}
|
|
if _, ok := controller.activeTurn("room-1", "agent-session-1"); ok {
|
|
t.Fatal("parent active turn map entry still exists")
|
|
}
|
|
}
|
|
|
|
func TestControllerStoreTurnSessionRejectsOlderAdapterLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
controller := NewController(nil, nil)
|
|
turnID := "parent-turn-1"
|
|
current := Session{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderClaudeCode,
|
|
Status: SessionStatusWorking,
|
|
LifecycleAuthority: true,
|
|
LifecycleSeq: 3,
|
|
TurnLifecycle: &TurnLifecycle{
|
|
ActiveTurnID: &turnID,
|
|
Phase: "running",
|
|
},
|
|
SubmitAvailability: blockedSubmitAvailability("active_turn"),
|
|
}
|
|
controller.store(current)
|
|
controller.mu.Lock()
|
|
controller.turns[sessionKey("room-1", "agent-session-1")] = activeTurn{turnID: turnID}
|
|
controller.mu.Unlock()
|
|
|
|
outcome := "completed"
|
|
stale := current
|
|
stale.Status = SessionStatusReady
|
|
stale.LifecycleSeq = 2
|
|
stale.TurnLifecycle = &TurnLifecycle{
|
|
Phase: "settled",
|
|
Outcome: &outcome,
|
|
}
|
|
stale.SubmitAvailability = availableSubmitAvailability()
|
|
|
|
if _, ok := controller.storeTurnSession(stale, turnID); ok {
|
|
t.Fatal("older adapter lifecycle overwrote current session")
|
|
}
|
|
stored, ok := controller.get("room-1", "agent-session-1")
|
|
if !ok {
|
|
t.Fatal("stored session missing")
|
|
}
|
|
if stored.LifecycleSeq != current.LifecycleSeq ||
|
|
stored.Status != SessionStatusWorking ||
|
|
stored.TurnLifecycle == nil ||
|
|
stored.TurnLifecycle.ActiveTurnID == nil ||
|
|
*stored.TurnLifecycle.ActiveTurnID != turnID ||
|
|
stored.TurnLifecycle.Phase != "running" {
|
|
t.Fatalf("stored session = %#v, want newer running lifecycle preserved", stored)
|
|
}
|
|
}
|
|
|
|
func TestControllerFinishTurnDoesNotRestoreClosedSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := &recordingStartAdapter{provider: ProviderCodex}
|
|
controller := NewController([]Adapter{adapter}, nil)
|
|
started, err := controller.Start(context.Background(), StartInput{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderCodex,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
turnID := "turn-1"
|
|
staleTurnSession := started.Session
|
|
staleTurnSession.Status = SessionStatusCanceled
|
|
staleTurnSession.TurnLifecycle = &TurnLifecycle{
|
|
ActiveTurnID: &turnID,
|
|
Phase: "running",
|
|
}
|
|
controller.store(staleTurnSession)
|
|
controller.mu.Lock()
|
|
controller.turns[sessionKey(staleTurnSession.RoomID, staleTurnSession.AgentSessionID)] = activeTurn{turnID: turnID}
|
|
controller.mu.Unlock()
|
|
|
|
if _, err := controller.Close(context.Background(), CloseInput{
|
|
RoomID: staleTurnSession.RoomID,
|
|
AgentSessionID: staleTurnSession.AgentSessionID,
|
|
}); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
// A turn goroutine can finish after Close has removed its session. Its stale
|
|
// snapshot must not recreate the closed session and shadow the next Start.
|
|
controller.finishTurn(staleTurnSession, turnID)
|
|
if restored, ok := controller.Session(staleTurnSession.RoomID, staleTurnSession.AgentSessionID); ok {
|
|
t.Fatalf("late turn finish restored closed session: %#v", restored)
|
|
}
|
|
}
|
|
|
|
func TestControllerFinishTurnDoesNotOverwriteRestartedSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := &recordingStartAdapter{provider: ProviderCodex}
|
|
controller := NewController([]Adapter{adapter}, nil)
|
|
started, err := controller.Start(context.Background(), StartInput{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderCodex,
|
|
Title: "old session",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
turnID := "turn-1"
|
|
staleTurnSession := started.Session
|
|
staleTurnSession.Status = SessionStatusCanceled
|
|
staleTurnSession.TurnLifecycle = &TurnLifecycle{
|
|
ActiveTurnID: &turnID,
|
|
Phase: "running",
|
|
}
|
|
controller.store(staleTurnSession)
|
|
controller.mu.Lock()
|
|
controller.turns[sessionKey(staleTurnSession.RoomID, staleTurnSession.AgentSessionID)] = activeTurn{turnID: turnID}
|
|
controller.mu.Unlock()
|
|
|
|
if _, err := controller.Close(context.Background(), CloseInput{
|
|
RoomID: staleTurnSession.RoomID,
|
|
AgentSessionID: staleTurnSession.AgentSessionID,
|
|
}); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
if _, err := controller.Start(context.Background(), StartInput{
|
|
RoomID: staleTurnSession.RoomID,
|
|
AgentSessionID: staleTurnSession.AgentSessionID,
|
|
Provider: ProviderCodex,
|
|
Title: "fresh session",
|
|
}); err != nil {
|
|
t.Fatalf("restart: %v", err)
|
|
}
|
|
|
|
// The canceled turn can unwind after a same-ID session has restarted. Its
|
|
// stale snapshot must not replace the fresh controller session.
|
|
if _, ok := controller.storeTurnSession(staleTurnSession, turnID); ok {
|
|
t.Fatal("late turn event stored stale session after restart")
|
|
}
|
|
controller.finishTurn(staleTurnSession, turnID)
|
|
restarted, ok := controller.Session(staleTurnSession.RoomID, staleTurnSession.AgentSessionID)
|
|
if !ok {
|
|
t.Fatal("restarted session missing")
|
|
}
|
|
if restarted.Title != "fresh session" || restarted.Status != SessionStatusReady {
|
|
t.Fatalf("restarted session overwritten by stale turn: %#v", restarted)
|
|
}
|
|
}
|
|
|
|
func TestControllerFinishTurnReconcilesCreatedStatusToReady(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
controller := NewController(nil, nil)
|
|
turnID := "turn-1"
|
|
session := Session{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderTuttiAgent,
|
|
Status: "created",
|
|
TurnLifecycle: &TurnLifecycle{
|
|
ActiveTurnID: &turnID,
|
|
Phase: "running",
|
|
},
|
|
SubmitAvailability: blockedSubmitAvailability("active_turn"),
|
|
}
|
|
controller.store(session)
|
|
controller.mu.Lock()
|
|
controller.turns[sessionKey(session.RoomID, session.AgentSessionID)] = activeTurn{turnID: turnID}
|
|
controller.mu.Unlock()
|
|
|
|
outcome := "completed"
|
|
session.TurnLifecycle = &TurnLifecycle{Phase: "settled", Outcome: &outcome}
|
|
session.SubmitAvailability = availableSubmitAvailability()
|
|
controller.finishTurn(session, turnID)
|
|
|
|
stored, ok := controller.get(session.RoomID, session.AgentSessionID)
|
|
if !ok {
|
|
t.Fatal("stored session missing")
|
|
}
|
|
if stored.Status != SessionStatusReady {
|
|
t.Fatalf("session status = %q, want ready", stored.Status)
|
|
}
|
|
if stored.SubmitAvailability == nil || stored.SubmitAvailability.State != "available" {
|
|
t.Fatalf("submit availability = %#v, want available", stored.SubmitAvailability)
|
|
}
|
|
}
|