源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
package agentruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
||||
)
|
||||
|
||||
type sideConformanceAdapter struct {
|
||||
mu sync.Mutex
|
||||
live map[string]bool
|
||||
parentEntered chan struct{}
|
||||
parentRelease chan struct{}
|
||||
resumeCalls int
|
||||
sessionSink SessionEventSink
|
||||
openErr error
|
||||
capabilities *SideConversationCapabilities
|
||||
openCaps *SideConversationCapabilities
|
||||
openSession *Session
|
||||
openEntered chan struct{}
|
||||
openRelease chan struct{}
|
||||
closedIDs []string
|
||||
}
|
||||
|
||||
func newSideConformanceAdapter() *sideConformanceAdapter {
|
||||
return &sideConformanceAdapter{
|
||||
live: make(map[string]bool),
|
||||
parentEntered: make(chan struct{}),
|
||||
parentRelease: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (*sideConformanceAdapter) Provider() string { return "side-conformance" }
|
||||
|
||||
func (a *sideConformanceAdapter) Start(_ context.Context, session Session) ([]activityshared.Event, error) {
|
||||
a.mu.Lock()
|
||||
a.live[session.AgentSessionID] = true
|
||||
a.mu.Unlock()
|
||||
return []activityshared.Event{
|
||||
newSessionActivityEvent(session, EventSessionStarted, SessionStatusReady, nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *sideConformanceAdapter) Resume(_ context.Context, session Session) error {
|
||||
a.mu.Lock()
|
||||
a.resumeCalls++
|
||||
a.live[session.AgentSessionID] = true
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *sideConformanceAdapter) Close(_ context.Context, session Session) error {
|
||||
a.mu.Lock()
|
||||
a.live[session.AgentSessionID] = false
|
||||
a.closedIDs = append(a.closedIDs, session.AgentSessionID)
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *sideConformanceAdapter) Exec(
|
||||
ctx context.Context,
|
||||
session Session,
|
||||
_ []PromptContentBlock,
|
||||
_ string,
|
||||
turnID string,
|
||||
emit EventSink,
|
||||
_ CommandSnapshotSink,
|
||||
) ([]activityshared.Event, error) {
|
||||
if !session.IsSideConversation() {
|
||||
close(a.parentEntered)
|
||||
select {
|
||||
case <-a.parentRelease:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
events := []activityshared.Event{
|
||||
newTurnActivityEvent(
|
||||
session, EventTurnCompleted, turnID, SessionStatusReady, "", "", nil,
|
||||
),
|
||||
}
|
||||
if emit != nil {
|
||||
emit(events)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (*sideConformanceAdapter) Cancel(
|
||||
_ context.Context,
|
||||
session Session,
|
||||
turnID string,
|
||||
) ([]activityshared.Event, error) {
|
||||
return []activityshared.Event{
|
||||
newTurnActivityEvent(
|
||||
session, EventTurnCanceled, turnID, SessionStatusCanceled, "", "", nil,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *sideConformanceAdapter) HasLiveSession(session Session) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.live[session.AgentSessionID]
|
||||
}
|
||||
|
||||
func (a *sideConformanceAdapter) SideCapabilities(
|
||||
context.Context,
|
||||
Session,
|
||||
) (SideConversationCapabilities, error) {
|
||||
if a.capabilities != nil {
|
||||
return *a.capabilities, nil
|
||||
}
|
||||
return SideConversationCapabilities{
|
||||
Supported: true,
|
||||
ActiveSourceTurn: true,
|
||||
Ephemeral: true,
|
||||
HideInheritedTurns: true,
|
||||
ModelBoundaryInjected: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *sideConformanceAdapter) OpenSide(
|
||||
ctx context.Context,
|
||||
input SideConversationAdapterOpenInput,
|
||||
) (SideConversationOpenResult, error) {
|
||||
if a.openEntered != nil {
|
||||
close(a.openEntered)
|
||||
select {
|
||||
case <-a.openRelease:
|
||||
case <-ctx.Done():
|
||||
return SideConversationOpenResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
side := input.Side
|
||||
if a.sessionSink != nil {
|
||||
a.sessionSink(side.AgentSessionID, []activityshared.Event{
|
||||
newSessionActivityEvent(
|
||||
side, EventSessionUpdated, SessionStatusReady,
|
||||
map[string]any{"opening": true},
|
||||
),
|
||||
})
|
||||
}
|
||||
if a.openErr != nil {
|
||||
return SideConversationOpenResult{}, a.openErr
|
||||
}
|
||||
side.ProviderSessionID = "provider-side"
|
||||
a.mu.Lock()
|
||||
a.live[side.AgentSessionID] = true
|
||||
a.mu.Unlock()
|
||||
if a.openSession != nil {
|
||||
side = *a.openSession
|
||||
}
|
||||
openCapabilities, _ := a.SideCapabilities(context.Background(), input.Source)
|
||||
if a.openCaps != nil {
|
||||
openCapabilities = *a.openCaps
|
||||
}
|
||||
return SideConversationOpenResult{
|
||||
Session: side,
|
||||
Capabilities: openCapabilities,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestSideConversationOpenSerializesSourceClose(t *testing.T) {
|
||||
adapter := newSideConformanceAdapter()
|
||||
adapter.openEntered = make(chan struct{})
|
||||
adapter.openRelease = make(chan struct{})
|
||||
controller := NewController([]Adapter{adapter}, nil)
|
||||
if _, err := controller.Start(t.Context(), StartInput{
|
||||
RoomID: "workspace-side-lock", AgentSessionID: "parent",
|
||||
Provider: adapter.Provider(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
openDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := controller.OpenSide(t.Context(), SideConversationOpenInput{
|
||||
RoomID: "workspace-side-lock", SourceAgentSessionID: "parent",
|
||||
SideAgentSessionID: "side-lock", RequestID: "open-lock",
|
||||
})
|
||||
openDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-adapter.openEntered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Side open did not reach provider")
|
||||
}
|
||||
|
||||
closeDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := controller.Close(t.Context(), CloseInput{
|
||||
RoomID: "workspace-side-lock", AgentSessionID: "parent",
|
||||
})
|
||||
closeDone <- err
|
||||
}()
|
||||
lockDeadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
controller.mu.Lock()
|
||||
sourceLock := controller.lifecycleLocks[sessionKey(
|
||||
"workspace-side-lock",
|
||||
"parent",
|
||||
)]
|
||||
sourceLockRefs := 0
|
||||
if sourceLock != nil {
|
||||
sourceLockRefs = sourceLock.refs
|
||||
}
|
||||
controller.mu.Unlock()
|
||||
if sourceLockRefs >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(lockDeadline) {
|
||||
t.Fatal("source close did not contend on the Side snapshot lock")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
select {
|
||||
case err := <-closeDone:
|
||||
t.Fatalf("source close overtook Side snapshot: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
close(adapter.openRelease)
|
||||
if err := <-openDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-closeDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *sideConformanceAdapter) SetSessionEventSink(sink SessionEventSink) {
|
||||
a.sessionSink = sink
|
||||
}
|
||||
|
||||
type sessionRecordingObserver struct {
|
||||
mu sync.Mutex
|
||||
sessions []string
|
||||
}
|
||||
|
||||
func (o *sessionRecordingObserver) ObserveRuntimeStreamEvents(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
sessionID string,
|
||||
_ []StreamEvent,
|
||||
) error {
|
||||
o.mu.Lock()
|
||||
o.sessions = append(o.sessions, sessionID)
|
||||
o.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*sessionRecordingObserver) ForgetSideConversation(string, string) {}
|
||||
|
||||
func (o *sessionRecordingObserver) contains(sessionID string) bool {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
for _, candidate := range o.sessions {
|
||||
if candidate == sessionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSideConversationOpensDuringActiveParentWithoutDurableWrites(t *testing.T) {
|
||||
adapter := newSideConformanceAdapter()
|
||||
reporter := &recordingReporter{}
|
||||
controller := NewController([]Adapter{adapter}, reporter)
|
||||
canonicalObserver := &sessionRecordingObserver{}
|
||||
sideObserver := &sessionRecordingObserver{}
|
||||
controller.SetStreamEventObserver(canonicalObserver)
|
||||
controller.SetSideStreamEventObserver(sideObserver)
|
||||
|
||||
started, err := controller.Start(t.Context(), StartInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "parent", Provider: adapter.Provider(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := controller.Exec(t.Context(), ExecInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "parent", TurnID: "parent-turn",
|
||||
Content: []PromptContentBlock{{Type: "text", Text: "keep working"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-adapter.parentEntered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("parent turn did not become active")
|
||||
}
|
||||
before := reporter.waitForCalls(t, 2)
|
||||
|
||||
opened, err := controller.OpenSide(t.Context(), SideConversationOpenInput{
|
||||
RoomID: "workspace-side", SourceAgentSessionID: "parent",
|
||||
SideAgentSessionID: "side-1", RequestID: "open-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opened.Session.IsSideConversation() ||
|
||||
opened.Session.SourceAgentSessionID != started.Session.AgentSessionID ||
|
||||
opened.Session.ProviderSessionID != "provider-side" ||
|
||||
opened.Session.Resumable {
|
||||
t.Fatalf("opened side = %#v", opened.Session)
|
||||
}
|
||||
if !controller.HasActiveTurn("workspace-side", "parent") {
|
||||
t.Fatal("opening Side disturbed the active parent turn")
|
||||
}
|
||||
if err := controller.DurablyReportSubmitProvenance(
|
||||
t.Context(),
|
||||
SubmitProvenanceInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "side-1",
|
||||
TurnID: "side-turn", ClientSubmitID: "side-submit",
|
||||
Content: []PromptContentBlock{{Type: "text", Text: "side question"}},
|
||||
},
|
||||
); !errors.Is(err, ErrSideConversationUnsupported) {
|
||||
t.Fatalf("Side submit provenance error = %v, want unsupported", err)
|
||||
}
|
||||
if err := controller.reportGoalReconcileDurable(
|
||||
t.Context(),
|
||||
opened.Session,
|
||||
GoalReconcileDurableRequest{RequestID: "side-goal"},
|
||||
); err != nil {
|
||||
t.Fatalf("Side goal reconcile durable sink error = %v", err)
|
||||
}
|
||||
if _, err := controller.BindGoalProvenance(
|
||||
t.Context(),
|
||||
opened.Session,
|
||||
"side-fingerprint",
|
||||
GoalProvenanceBinding{},
|
||||
); !errors.Is(err, ErrSideConversationUnsupported) {
|
||||
t.Fatalf("Side goal provenance error = %v, want unsupported", err)
|
||||
}
|
||||
if _, err := controller.Exec(t.Context(), ExecInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "side-1",
|
||||
TurnID: "side-turn-with-canonical-submit",
|
||||
ClientSubmitID: "side-submit",
|
||||
CanonicalSubmitOccurredAtUnixMS: 1,
|
||||
Content: []PromptContentBlock{{
|
||||
Type: "text", Text: "must remain transient",
|
||||
}},
|
||||
}); err == nil || !strings.Contains(err.Error(), "canonical submit occurrence") {
|
||||
t.Fatalf(
|
||||
"Side canonical submit occurrence error = %v, want transient-lane rejection",
|
||||
err,
|
||||
)
|
||||
}
|
||||
if _, err := controller.Exec(t.Context(), ExecInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "side-1", TurnID: "side-turn",
|
||||
ClientSubmitID: "side-submit",
|
||||
Content: []PromptContentBlock{{Type: "text", Text: "side question"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForSessionStatus(
|
||||
t, controller, "workspace-side", "side-1", SessionStatusReady,
|
||||
)
|
||||
if _, err := controller.Close(t.Context(), CloseInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "side-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(reporter.snapshot()); got != len(before) {
|
||||
t.Fatalf("durable report calls = %d, want unchanged %d", got, len(before))
|
||||
}
|
||||
if canonicalObserver.contains("side-1") {
|
||||
t.Fatal("side events leaked into the canonical stream observer")
|
||||
}
|
||||
if !sideObserver.contains("side-1") {
|
||||
t.Fatal("side events did not reach the transient observer")
|
||||
}
|
||||
if sessions := controller.Sessions("workspace-side"); len(sessions) != 1 ||
|
||||
sessions[0].AgentSessionID != "parent" {
|
||||
t.Fatalf("canonical sessions = %#v, want only parent", sessions)
|
||||
}
|
||||
|
||||
close(adapter.parentRelease)
|
||||
waitForSessionStatus(
|
||||
t, controller, "workspace-side", "parent", SessionStatusReady,
|
||||
)
|
||||
}
|
||||
|
||||
func TestExpiredSideConversationNeverResumes(t *testing.T) {
|
||||
adapter := newSideConformanceAdapter()
|
||||
controller := NewController([]Adapter{adapter}, nil)
|
||||
if _, err := controller.Start(t.Context(), StartInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "parent", Provider: adapter.Provider(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := controller.OpenSide(t.Context(), SideConversationOpenInput{
|
||||
RoomID: "workspace-side", SourceAgentSessionID: "parent",
|
||||
SideAgentSessionID: "side-1", RequestID: "open-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
adapter.live["side-1"] = false
|
||||
adapter.mu.Unlock()
|
||||
|
||||
_, err := controller.Exec(t.Context(), ExecInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "side-1", TurnID: "side-turn",
|
||||
Content: []PromptContentBlock{{Type: "text", Text: "late question"}},
|
||||
})
|
||||
if !errors.Is(err, ErrSideConversationExpired) {
|
||||
t.Fatalf("Exec error = %v, want ErrSideConversationExpired", err)
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
defer adapter.mu.Unlock()
|
||||
if adapter.resumeCalls != 0 {
|
||||
t.Fatalf("side resume calls = %d, want 0", adapter.resumeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSideCapabilitiesDelegatesAfterCanonicalSourceIdleRelease(t *testing.T) {
|
||||
adapter := newSideConformanceAdapter()
|
||||
controller := NewController([]Adapter{adapter}, nil)
|
||||
if _, err := controller.Resume(t.Context(), ResumeInput{
|
||||
RoomID: "workspace-side",
|
||||
AgentSessionID: "parent",
|
||||
Provider: adapter.Provider(),
|
||||
ProviderSessionID: "provider-parent",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
resumeCalls := adapter.resumeCalls
|
||||
adapter.live["parent"] = false
|
||||
adapter.mu.Unlock()
|
||||
|
||||
capabilities, err := controller.SideCapabilities(
|
||||
t.Context(), "workspace-side", "parent",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validRequiredSideCapabilities(capabilities) {
|
||||
t.Fatalf("SideCapabilities = %#v, want supported", capabilities)
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
defer adapter.mu.Unlock()
|
||||
if adapter.resumeCalls != resumeCalls {
|
||||
t.Fatalf("source resume calls = %d, want %d", adapter.resumeCalls, resumeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSideDelegatesAfterCanonicalSourceIdleRelease(t *testing.T) {
|
||||
adapter := newSideConformanceAdapter()
|
||||
controller := NewController([]Adapter{adapter}, nil)
|
||||
if _, err := controller.Resume(t.Context(), ResumeInput{
|
||||
RoomID: "workspace-side",
|
||||
AgentSessionID: "parent",
|
||||
Provider: adapter.Provider(),
|
||||
ProviderSessionID: "provider-parent",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
resumeCalls := adapter.resumeCalls
|
||||
adapter.live["parent"] = false
|
||||
adapter.mu.Unlock()
|
||||
|
||||
opened, err := controller.OpenSide(t.Context(), SideConversationOpenInput{
|
||||
RoomID: "workspace-side",
|
||||
SourceAgentSessionID: "parent",
|
||||
SideAgentSessionID: "side-after-release",
|
||||
RequestID: "open-after-release",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opened.Session.ProviderSessionID != "provider-side" {
|
||||
t.Fatalf("opened Side = %#v", opened.Session)
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
defer adapter.mu.Unlock()
|
||||
if adapter.resumeCalls != resumeCalls {
|
||||
t.Fatalf("source resume calls = %d, want %d", adapter.resumeCalls, resumeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSideConversationRequiredCapabilitiesFailClosed(t *testing.T) {
|
||||
full := SideConversationCapabilities{
|
||||
Supported: true, ActiveSourceTurn: true, Ephemeral: true,
|
||||
HideInheritedTurns: true, ModelBoundaryInjected: true,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
preflight SideConversationCapabilities
|
||||
open *SideConversationCapabilities
|
||||
}{
|
||||
{
|
||||
name: "preflight lacks ephemeral",
|
||||
preflight: SideConversationCapabilities{
|
||||
Supported: true, ActiveSourceTurn: true,
|
||||
HideInheritedTurns: true, ModelBoundaryInjected: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open result weakens boundary",
|
||||
preflight: full,
|
||||
open: &SideConversationCapabilities{
|
||||
Supported: true, ActiveSourceTurn: true, Ephemeral: true,
|
||||
HideInheritedTurns: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
adapter := newSideConformanceAdapter()
|
||||
adapter.capabilities = &test.preflight
|
||||
adapter.openCaps = test.open
|
||||
controller := NewController([]Adapter{adapter}, nil)
|
||||
if _, err := controller.Start(t.Context(), StartInput{
|
||||
RoomID: "workspace-caps", AgentSessionID: "parent",
|
||||
Provider: adapter.Provider(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := controller.OpenSide(t.Context(), SideConversationOpenInput{
|
||||
RoomID: "workspace-caps", SourceAgentSessionID: "parent",
|
||||
SideAgentSessionID: "side-caps", RequestID: "open-caps",
|
||||
})
|
||||
if !errors.Is(err, ErrSideConversationUnsupported) {
|
||||
t.Fatalf("OpenSide error = %v, want unsupported", err)
|
||||
}
|
||||
if _, found := controller.Session("workspace-caps", "side-caps"); found {
|
||||
t.Fatal("failed capability validation left Side registered")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSideOpenIdentityNeverClosesParent(t *testing.T) {
|
||||
adapter := newSideConformanceAdapter()
|
||||
controller := NewController([]Adapter{adapter}, nil)
|
||||
started, err := controller.Start(t.Context(), StartInput{
|
||||
RoomID: "workspace-invalid-side", AgentSessionID: "parent",
|
||||
Provider: adapter.Provider(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := started.Session
|
||||
adapter.openSession = &parent
|
||||
_, err = controller.OpenSide(t.Context(), SideConversationOpenInput{
|
||||
RoomID: "workspace-invalid-side", SourceAgentSessionID: "parent",
|
||||
SideAgentSessionID: "side-invalid", RequestID: "open-invalid",
|
||||
})
|
||||
if !errors.Is(err, ErrSideConversationConflict) {
|
||||
t.Fatalf("OpenSide error = %v, want conflict", err)
|
||||
}
|
||||
adapter.mu.Lock()
|
||||
parentLive := adapter.live["parent"]
|
||||
closedIDs := append([]string(nil), adapter.closedIDs...)
|
||||
adapter.mu.Unlock()
|
||||
if !parentLive {
|
||||
t.Fatal("invalid Side result closed the parent")
|
||||
}
|
||||
for _, closedID := range closedIDs {
|
||||
if closedID == "parent" {
|
||||
t.Fatal("invalid Side result invoked Close with parent identity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedSideOpenDiscardsBufferedTransientEvents(t *testing.T) {
|
||||
adapter := newSideConformanceAdapter()
|
||||
adapter.openErr = errors.New("fork rejected")
|
||||
controller := NewController([]Adapter{adapter}, nil)
|
||||
observer := &sessionRecordingObserver{}
|
||||
controller.SetSideStreamEventObserver(observer)
|
||||
if _, err := controller.Start(t.Context(), StartInput{
|
||||
RoomID: "workspace-side", AgentSessionID: "parent", Provider: adapter.Provider(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := controller.OpenSide(t.Context(), SideConversationOpenInput{
|
||||
RoomID: "workspace-side", SourceAgentSessionID: "parent",
|
||||
SideAgentSessionID: "side-failed", RequestID: "open-failed",
|
||||
})
|
||||
if !errors.Is(err, adapter.openErr) {
|
||||
t.Fatalf("OpenSide error = %v", err)
|
||||
}
|
||||
if observer.contains("side-failed") {
|
||||
t.Fatal("failed Side leaked its buffered transient events")
|
||||
}
|
||||
if _, found := controller.Session("workspace-side", "side-failed"); found {
|
||||
t.Fatal("failed Side remained registered")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user