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
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
type canonicalSubmitSequenceAdapter struct {
|
|
recordingStartAdapter
|
|
release <-chan struct{}
|
|
emitted chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func TestCanonicalSubmitFactRequiresCompleteIdentityAndOccurrence(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
clientSubmitID string
|
|
occurredAtUnixMS int64
|
|
}{
|
|
{name: "identity_without_occurrence", clientSubmitID: "submit-1"},
|
|
{name: "occurrence_without_identity", occurredAtUnixMS: 1_234},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
if _, err := newCanonicalSubmitFact(testCase.clientSubmitID, testCase.occurredAtUnixMS); err == nil {
|
|
t.Fatal("expected incomplete canonical submit fact to fail")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func (a *canonicalSubmitSequenceAdapter) ExecAsync(
|
|
ctx context.Context,
|
|
session Session,
|
|
content []PromptContentBlock,
|
|
displayPrompt string,
|
|
turnID string,
|
|
emit EventSink,
|
|
_ CommandSnapshotSink,
|
|
) error {
|
|
select {
|
|
case <-a.release:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
explicitDisplayPrompt, visibleText := explicitAndVisiblePromptText(content, displayPrompt)
|
|
if emit != nil {
|
|
emit([]activityshared.Event{
|
|
newUserPromptActivityEvent(
|
|
ctx,
|
|
session,
|
|
content,
|
|
explicitDisplayPrompt,
|
|
visibleText,
|
|
turnID,
|
|
nil,
|
|
),
|
|
})
|
|
}
|
|
a.once.Do(func() { close(a.emitted) })
|
|
return nil
|
|
}
|
|
|
|
func TestCanonicalSubmitSequenceIsStableAcrossRuntimeAndProvenanceOrder(t *testing.T) {
|
|
for _, provenanceFirst := range []bool{false, true} {
|
|
name := "runtime-first"
|
|
if provenanceFirst {
|
|
name = "provenance-first"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
release := make(chan struct{})
|
|
adapter := &canonicalSubmitSequenceAdapter{
|
|
recordingStartAdapter: recordingStartAdapter{provider: "canonical-submit-sequence"},
|
|
release: release,
|
|
emitted: make(chan struct{}),
|
|
}
|
|
reporter := &recordingReporter{}
|
|
controller := NewController([]Adapter{adapter}, reporter)
|
|
started, err := controller.Start(t.Context(), StartInput{
|
|
RoomID: "workspace-" + name,
|
|
AgentSessionID: "session-" + name,
|
|
Provider: adapter.Provider(),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const occurredAtUnixMS = int64(1_234)
|
|
const clientSubmitID = "submit-1"
|
|
content := textPrompt("hello")
|
|
execResult, err := controller.Exec(t.Context(), ExecInput{
|
|
RoomID: started.Session.RoomID,
|
|
AgentSessionID: started.Session.AgentSessionID,
|
|
TurnID: "turn-1",
|
|
ClientSubmitID: clientSubmitID,
|
|
CanonicalSubmitOccurredAtUnixMS: occurredAtUnixMS,
|
|
Content: content,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !provenanceFirst {
|
|
close(release)
|
|
waitForCanonicalSubmitEmission(t, adapter.emitted)
|
|
waitForCanonicalSubmitMessageReports(t, reporter, clientSubmitID, 1)
|
|
}
|
|
if err := controller.DurablyReportSubmitProvenance(t.Context(), SubmitProvenanceInput{
|
|
RoomID: started.Session.RoomID,
|
|
AgentSessionID: started.Session.AgentSessionID,
|
|
TurnID: execResult.TurnID,
|
|
ClientSubmitID: clientSubmitID,
|
|
CanonicalSubmitOccurredAtUnixMS: occurredAtUnixMS,
|
|
Content: content,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if provenanceFirst {
|
|
close(release)
|
|
waitForCanonicalSubmitEmission(t, adapter.emitted)
|
|
}
|
|
|
|
updates := waitForCanonicalSubmitMessageReports(t, reporter, clientSubmitID, 2)
|
|
if !reflect.DeepEqual(updates[0], updates[1]) {
|
|
t.Fatalf("canonical submit updates differ:\nfirst=%#v\nsecond=%#v", updates[0], updates[1])
|
|
}
|
|
if updates[0].Seq != uint64(occurredAtUnixMS) || updates[0].OccurredAtUnixMS != occurredAtUnixMS {
|
|
t.Fatalf("canonical submit sequence = %d occurredAt=%d", updates[0].Seq, updates[0].OccurredAtUnixMS)
|
|
}
|
|
projected := agentsessionstore.SessionMessageUpdateFromActivityUpdate(updates[0])
|
|
if projected.Payload["seq"] != uint64(occurredAtUnixMS) {
|
|
t.Fatalf("canonical payload seq = %#v", projected.Payload["seq"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func waitForCanonicalSubmitEmission(t *testing.T, emitted <-chan struct{}) {
|
|
t.Helper()
|
|
select {
|
|
case <-emitted:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timed out waiting for canonical submit user message")
|
|
}
|
|
}
|
|
|
|
func waitForCanonicalSubmitMessageReports(
|
|
t *testing.T,
|
|
reporter *recordingReporter,
|
|
clientSubmitID string,
|
|
count int,
|
|
) []agentsessionstore.WorkspaceAgentMessageUpdate {
|
|
t.Helper()
|
|
messageID := userPromptActivityMessageIDFromClientSubmitID(clientSubmitID)
|
|
var matches []agentsessionstore.WorkspaceAgentMessageUpdate
|
|
reporter.waitForReports(t, "canonical submit message reports", func(calls []reportCall) bool {
|
|
matches = matches[:0]
|
|
for _, call := range calls {
|
|
for _, update := range call.report.MessageUpdates {
|
|
if update.MessageID == messageID {
|
|
matches = append(matches, update)
|
|
}
|
|
}
|
|
}
|
|
return len(matches) >= count
|
|
})
|
|
return append([]agentsessionstore.WorkspaceAgentMessageUpdate(nil), matches...)
|
|
}
|
|
|
|
type submittedTurnBarrierReporter struct {
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
err error
|
|
once sync.Once
|
|
input agentsessionstore.ReportActivityInput
|
|
provenanceCalls int
|
|
}
|
|
|
|
func (r *submittedTurnBarrierReporter) Report(ctx context.Context, input agentsessionstore.ReportActivityInput) error {
|
|
r.input = input
|
|
r.once.Do(func() { close(r.entered) })
|
|
select {
|
|
case <-r.release:
|
|
return r.err
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (r *submittedTurnBarrierReporter) ReportSubmitProvenance(ctx context.Context, report agentsessionstore.ReportActivityInput) error {
|
|
r.provenanceCalls++
|
|
return r.Report(ctx, report)
|
|
}
|
|
|
|
type executionSignalAdapter struct {
|
|
recordingStartAdapter
|
|
executed chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (a *executionSignalAdapter) Exec(context.Context, Session, []PromptContentBlock, string, string, EventSink, CommandSnapshotSink) ([]activityshared.Event, error) {
|
|
a.once.Do(func() { close(a.executed) })
|
|
return nil, nil
|
|
}
|
|
|
|
func TestControllerExecWaitsForSubmittedTurnDurableReportBeforeProviderStart(t *testing.T) {
|
|
t.Parallel()
|
|
reporter := &submittedTurnBarrierReporter{entered: make(chan struct{}), release: make(chan struct{})}
|
|
adapter := &executionSignalAdapter{
|
|
recordingStartAdapter: recordingStartAdapter{provider: ProviderCodex},
|
|
executed: make(chan struct{}),
|
|
}
|
|
controller := NewController([]Adapter{adapter}, reporter)
|
|
_, err := controller.Start(context.Background(), StartInput{
|
|
RoomID: "room-1", AgentSessionID: "agent-session-1", Provider: ProviderCodex, Provisional: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
_, execErr := controller.Exec(context.Background(), ExecInput{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
ClientSubmitID: "submit-1",
|
|
CanonicalSubmitOccurredAtUnixMS: 1_234,
|
|
Content: textPrompt("hello"),
|
|
})
|
|
execDone <- execErr
|
|
}()
|
|
select {
|
|
case <-reporter.entered:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for submitted-turn report")
|
|
}
|
|
select {
|
|
case <-adapter.executed:
|
|
t.Fatal("provider started before submitted Turn became durable")
|
|
case err := <-execDone:
|
|
t.Fatalf("Exec returned before submitted Turn became durable: %v", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
close(reporter.release)
|
|
select {
|
|
case err := <-execDone:
|
|
if err != nil {
|
|
t.Fatalf("Exec() error = %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Exec did not return after durable report")
|
|
}
|
|
if len(reporter.input.MessageUpdates) != 1 ||
|
|
reporter.input.MessageUpdates[0].MessageID != userPromptActivityMessageIDFromClientSubmitID("submit-1") ||
|
|
reporter.input.MessageUpdates[0].Role != string(activityshared.MessageRoleUser) ||
|
|
reporter.input.MessageUpdates[0].TurnID == "" {
|
|
t.Fatalf("submitted report message updates = %#v", reporter.input.MessageUpdates)
|
|
}
|
|
if reporter.provenanceCalls != 1 {
|
|
t.Fatalf("submit provenance calls = %d, want one atomic admission call", reporter.provenanceCalls)
|
|
}
|
|
select {
|
|
case <-adapter.executed:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("provider did not start after durable report")
|
|
}
|
|
}
|
|
|
|
func TestControllerExecRollsBackWhenSubmittedTurnDurableReportFails(t *testing.T) {
|
|
t.Parallel()
|
|
reporter := &submittedTurnBarrierReporter{
|
|
entered: make(chan struct{}), release: make(chan struct{}), err: errors.New("sqlite unavailable"),
|
|
}
|
|
close(reporter.release)
|
|
adapter := &executionSignalAdapter{
|
|
recordingStartAdapter: recordingStartAdapter{provider: ProviderCodex},
|
|
executed: make(chan struct{}),
|
|
}
|
|
controller := NewController([]Adapter{adapter}, reporter)
|
|
_, err := controller.Start(context.Background(), StartInput{
|
|
RoomID: "room-1", AgentSessionID: "agent-session-1", Provider: ProviderCodex, Provisional: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
if _, err := controller.Exec(context.Background(), ExecInput{
|
|
RoomID: "room-1", AgentSessionID: "agent-session-1", Content: textPrompt("hello"),
|
|
}); err == nil || !strings.Contains(err.Error(), "persist submitted agent turn") {
|
|
t.Fatalf("Exec() error = %v, want durable report failure", err)
|
|
}
|
|
select {
|
|
case <-adapter.executed:
|
|
t.Fatal("provider started after submitted Turn report failed")
|
|
default:
|
|
}
|
|
if controller.HasActiveTurn("room-1", "agent-session-1") {
|
|
t.Fatal("active Turn survived submitted report rollback")
|
|
}
|
|
stored, ok := controller.Session("room-1", "agent-session-1")
|
|
if !ok || stored.Status != SessionStatusReady || stored.TurnLifecycle != nil {
|
|
t.Fatalf("rolled-back session = %#v, ok=%v", stored, ok)
|
|
}
|
|
controller.mu.Lock()
|
|
provisional := controller.provisionalSessions[sessionKey("room-1", "agent-session-1")]
|
|
controller.mu.Unlock()
|
|
if !provisional {
|
|
t.Fatal("failed first Turn committed provisional session")
|
|
}
|
|
}
|